diff options
author | Clement Ho <ClemMakesApps@gmail.com> | 2017-06-09 14:28:28 -0500 |
---|---|---|
committer | Clement Ho <ClemMakesApps@gmail.com> | 2017-06-09 14:28:28 -0500 |
commit | 5bed8ac730f3849f550590ae9906166dd031dc87 (patch) | |
tree | bab3febb5105a9720e29abc2898ac440875c58c5 | |
parent | 66bbf30ed8bb006d9a968693fef266c86ec2325f (diff) | |
parent | abc61f260074663e5711d3814d9b7d301d07a259 (diff) | |
download | gitlab-ce-merge-master-9-3-stable.tar.gz |
Merge commit 'abc61f260074663e5711d3814d9b7d301d07a259' into merge-master-9-3-stablemerge-master-9-3-stable
2362 files changed, 46163 insertions, 14530 deletions
diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 00000000000..e5636a13783 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,38 @@ +--- +engines: + brakeman: + enabled: true + bundler-audit: + enabled: true + duplication: + enabled: true + config: + languages: + - ruby + - javascript + eslint: + enabled: true + fixme: + enabled: true + rubocop: + enabled: true +ratings: + paths: + - Gemfile.lock + - "**.erb" + - "**.haml" + - "**.rb" + - "**.rhtml" + - "**.slim" + - "**.inc" + - "**.js" + - "**.jsx" + - "**.module" +exclude_paths: +- config/ +- db/ +- features/ +- node_modules/ +- spec/ +- vendor/ +- lib/api/v3/ diff --git a/.eslintrc b/.eslintrc index aba8112c5a9..73cd7ecf66d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -27,6 +27,7 @@ }, "rules": { "filenames/match-regex": [2, "^[a-z0-9_]+$"], + "import/no-commonjs": "error", "no-multiple-empty-lines": ["error", { "max": 1 }], "promise/catch-or-return": "error" } diff --git a/.gitignore b/.gitignore index 0fb97ffb98e..89da29fd790 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ eslint-report.html .sass-cache/ /.secret /.vagrant +/.yarn-cache /.byebug_history /Vagrantfile /backups/* @@ -48,6 +49,7 @@ eslint-report.html /public/uploads/ /shared/artifacts/ /spec/javascripts/fixtures/blob/pdf/ +/spec/javascripts/fixtures/blob/balsamiq/ /rails_best_practices_output.html /tags /tmp/* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 23d2e48662c..9480157180b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,9 +1,10 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.3-golang-1.8-git-2.7-phantomjs-2.1-node-7.1-postgresql-9.6" cache: - key: "ruby-233" + key: "ruby-233-with-yarn" paths: - vendor/ruby + - .yarn-cache/ variables: MYSQL_ALLOW_EMPTY_PASSWORD: "1" @@ -62,6 +63,7 @@ stages: .only-master-and-ee-or-mysql: &only-master-and-ee-or-mysql only: - /mysql/ + - /-stable$/ - master@gitlab-org/gitlab-ce - master@gitlab/gitlabhq - tags@gitlab-org/gitlab-ce @@ -74,7 +76,7 @@ stages: # https://docs.gitlab.com/ce/development/writing_documentation.html#testing .except-docs: &except-docs except: - - /^docs\/.*/ + - /(^docs[\/-].*|.*-docs$)/ .rspec-knapsack: &rspec-knapsack stage: test @@ -83,7 +85,7 @@ stages: - JOB_NAME=( $CI_JOB_NAME ) - export CI_NODE_INDEX=${JOB_NAME[-2]} - export CI_NODE_TOTAL=${JOB_NAME[-1]} - - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_${JOB_NAME[1]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json + - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json - export KNAPSACK_GENERATE_REPORT=true - export CACHE_CLASSES=true - cp ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} ${KNAPSACK_REPORT_PATH} @@ -114,7 +116,7 @@ stages: - JOB_NAME=( $CI_JOB_NAME ) - export CI_NODE_INDEX=${JOB_NAME[-2]} - export CI_NODE_TOTAL=${JOB_NAME[-1]} - - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_${JOB_NAME[1]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json + - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json - export KNAPSACK_GENERATE_REPORT=true - export CACHE_CLASSES=true - cp ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} ${KNAPSACK_REPORT_PATH} @@ -138,9 +140,18 @@ stages: <<: *only-master-and-ee-or-mysql <<: *except-docs +.only-canonical-masters: &only-canonical-masters + only: + - master@gitlab-org/gitlab-ce + - master@gitlab-org/gitlab-ee + - master@gitlab/gitlabhq + - master@gitlab/gitlab-ee + # Trigger a package build on omnibus-gitlab repository build-package: + image: ruby:2.3-alpine + before_script: [] services: [] variables: SETUP_DB: "false" @@ -148,17 +159,7 @@ build-package: stage: build when: manual script: - # If no branch in omnibus is specified, trigger pipeline against master - - if [ -z "$OMNIBUS_BRANCH" ] ; then export OMNIBUS_BRANCH=master ;fi - - echo "token=${BUILD_TRIGGER_TOKEN}" > version_details - - echo "ref=${OMNIBUS_BRANCH}" >> version_details - - echo "variables[ALTERNATIVE_SOURCES]=true" >> version_details - - echo "variables[GITLAB_VERSION]=${CI_COMMIT_SHA}" >> version_details - # Collect version details of all components - - for f in *_VERSION; do echo "variables[$f]=$(cat $f)" >> version_details; done - # Trigger the API and pass values collected above as parameters to it - - cat version_details | tr '\n' '&' | curl -X POST https://gitlab.com/api/v4/projects/20699/trigger/pipeline --data-binary @- - - rm version_details + - scripts/trigger-build # Prepare and merge knapsack tests knapsack: @@ -176,17 +177,13 @@ knapsack: update-knapsack: <<: *knapsack-state <<: *dedicated-runner + <<: *only-canonical-masters stage: post-test script: - - scripts/merge-reports ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/rspec_pg_node_*.json - - scripts/merge-reports ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/spinach_pg_node_*.json + - scripts/merge-reports ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/rspec-pg_node_*.json + - scripts/merge-reports ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/spinach-pg_node_*.json - '[[ -z ${KNAPSACK_S3_BUCKET} ]] || scripts/sync-reports put $KNAPSACK_S3_BUCKET $KNAPSACK_RSPEC_SUITE_REPORT_PATH $KNAPSACK_SPINACH_SUITE_REPORT_PATH' - rm -f knapsack/${CI_PROJECT_NAME}/*_node_*.json - only: - - master@gitlab-org/gitlab-ce - - master@gitlab-org/gitlab-ee - - master@gitlab/gitlabhq - - master@gitlab/gitlab-ee setup-test-env: <<: *use-pg @@ -195,7 +192,7 @@ setup-test-env: stage: prepare script: - node --version - - yarn install --pure-lockfile + - yarn install --pure-lockfile --cache-folder .yarn-cache - bundle exec rake gitlab:assets:compile - bundle exec ruby -Ispec -e 'require "spec_helper" ; TestEnv.init' artifacts: @@ -205,76 +202,75 @@ setup-test-env: - public/assets - tmp/tests -rspec pg 0 20: *rspec-knapsack-pg -rspec pg 1 20: *rspec-knapsack-pg -rspec pg 2 20: *rspec-knapsack-pg -rspec pg 3 20: *rspec-knapsack-pg -rspec pg 4 20: *rspec-knapsack-pg -rspec pg 5 20: *rspec-knapsack-pg -rspec pg 6 20: *rspec-knapsack-pg -rspec pg 7 20: *rspec-knapsack-pg -rspec pg 8 20: *rspec-knapsack-pg -rspec pg 9 20: *rspec-knapsack-pg -rspec pg 10 20: *rspec-knapsack-pg -rspec pg 11 20: *rspec-knapsack-pg -rspec pg 12 20: *rspec-knapsack-pg -rspec pg 13 20: *rspec-knapsack-pg -rspec pg 14 20: *rspec-knapsack-pg -rspec pg 15 20: *rspec-knapsack-pg -rspec pg 16 20: *rspec-knapsack-pg -rspec pg 17 20: *rspec-knapsack-pg -rspec pg 18 20: *rspec-knapsack-pg -rspec pg 19 20: *rspec-knapsack-pg - -rspec mysql 0 20: *rspec-knapsack-mysql -rspec mysql 1 20: *rspec-knapsack-mysql -rspec mysql 2 20: *rspec-knapsack-mysql -rspec mysql 3 20: *rspec-knapsack-mysql -rspec mysql 4 20: *rspec-knapsack-mysql -rspec mysql 5 20: *rspec-knapsack-mysql -rspec mysql 6 20: *rspec-knapsack-mysql -rspec mysql 7 20: *rspec-knapsack-mysql -rspec mysql 8 20: *rspec-knapsack-mysql -rspec mysql 9 20: *rspec-knapsack-mysql -rspec mysql 10 20: *rspec-knapsack-mysql -rspec mysql 11 20: *rspec-knapsack-mysql -rspec mysql 12 20: *rspec-knapsack-mysql -rspec mysql 13 20: *rspec-knapsack-mysql -rspec mysql 14 20: *rspec-knapsack-mysql -rspec mysql 15 20: *rspec-knapsack-mysql -rspec mysql 16 20: *rspec-knapsack-mysql -rspec mysql 17 20: *rspec-knapsack-mysql -rspec mysql 18 20: *rspec-knapsack-mysql -rspec mysql 19 20: *rspec-knapsack-mysql - -spinach pg 0 10: *spinach-knapsack-pg -spinach pg 1 10: *spinach-knapsack-pg -spinach pg 2 10: *spinach-knapsack-pg -spinach pg 3 10: *spinach-knapsack-pg -spinach pg 4 10: *spinach-knapsack-pg -spinach pg 5 10: *spinach-knapsack-pg -spinach pg 6 10: *spinach-knapsack-pg -spinach pg 7 10: *spinach-knapsack-pg -spinach pg 8 10: *spinach-knapsack-pg -spinach pg 9 10: *spinach-knapsack-pg - -spinach mysql 0 10: *spinach-knapsack-mysql -spinach mysql 1 10: *spinach-knapsack-mysql -spinach mysql 2 10: *spinach-knapsack-mysql -spinach mysql 3 10: *spinach-knapsack-mysql -spinach mysql 4 10: *spinach-knapsack-mysql -spinach mysql 5 10: *spinach-knapsack-mysql -spinach mysql 6 10: *spinach-knapsack-mysql -spinach mysql 7 10: *spinach-knapsack-mysql -spinach mysql 8 10: *spinach-knapsack-mysql -spinach mysql 9 10: *spinach-knapsack-mysql - -# Other generic tests +rspec-pg 0 20: *rspec-knapsack-pg +rspec-pg 1 20: *rspec-knapsack-pg +rspec-pg 2 20: *rspec-knapsack-pg +rspec-pg 3 20: *rspec-knapsack-pg +rspec-pg 4 20: *rspec-knapsack-pg +rspec-pg 5 20: *rspec-knapsack-pg +rspec-pg 6 20: *rspec-knapsack-pg +rspec-pg 7 20: *rspec-knapsack-pg +rspec-pg 8 20: *rspec-knapsack-pg +rspec-pg 9 20: *rspec-knapsack-pg +rspec-pg 10 20: *rspec-knapsack-pg +rspec-pg 11 20: *rspec-knapsack-pg +rspec-pg 12 20: *rspec-knapsack-pg +rspec-pg 13 20: *rspec-knapsack-pg +rspec-pg 14 20: *rspec-knapsack-pg +rspec-pg 15 20: *rspec-knapsack-pg +rspec-pg 16 20: *rspec-knapsack-pg +rspec-pg 17 20: *rspec-knapsack-pg +rspec-pg 18 20: *rspec-knapsack-pg +rspec-pg 19 20: *rspec-knapsack-pg + +rspec-mysql 0 20: *rspec-knapsack-mysql +rspec-mysql 1 20: *rspec-knapsack-mysql +rspec-mysql 2 20: *rspec-knapsack-mysql +rspec-mysql 3 20: *rspec-knapsack-mysql +rspec-mysql 4 20: *rspec-knapsack-mysql +rspec-mysql 5 20: *rspec-knapsack-mysql +rspec-mysql 6 20: *rspec-knapsack-mysql +rspec-mysql 7 20: *rspec-knapsack-mysql +rspec-mysql 8 20: *rspec-knapsack-mysql +rspec-mysql 9 20: *rspec-knapsack-mysql +rspec-mysql 10 20: *rspec-knapsack-mysql +rspec-mysql 11 20: *rspec-knapsack-mysql +rspec-mysql 12 20: *rspec-knapsack-mysql +rspec-mysql 13 20: *rspec-knapsack-mysql +rspec-mysql 14 20: *rspec-knapsack-mysql +rspec-mysql 15 20: *rspec-knapsack-mysql +rspec-mysql 16 20: *rspec-knapsack-mysql +rspec-mysql 17 20: *rspec-knapsack-mysql +rspec-mysql 18 20: *rspec-knapsack-mysql +rspec-mysql 19 20: *rspec-knapsack-mysql + +spinach-pg 0 10: *spinach-knapsack-pg +spinach-pg 1 10: *spinach-knapsack-pg +spinach-pg 2 10: *spinach-knapsack-pg +spinach-pg 3 10: *spinach-knapsack-pg +spinach-pg 4 10: *spinach-knapsack-pg +spinach-pg 5 10: *spinach-knapsack-pg +spinach-pg 6 10: *spinach-knapsack-pg +spinach-pg 7 10: *spinach-knapsack-pg +spinach-pg 8 10: *spinach-knapsack-pg +spinach-pg 9 10: *spinach-knapsack-pg + +spinach-mysql 0 10: *spinach-knapsack-mysql +spinach-mysql 1 10: *spinach-knapsack-mysql +spinach-mysql 2 10: *spinach-knapsack-mysql +spinach-mysql 3 10: *spinach-knapsack-mysql +spinach-mysql 4 10: *spinach-knapsack-mysql +spinach-mysql 5 10: *spinach-knapsack-mysql +spinach-mysql 6 10: *spinach-knapsack-mysql +spinach-mysql 7 10: *spinach-knapsack-mysql +spinach-mysql 8 10: *spinach-knapsack-mysql +spinach-mysql 9 10: *spinach-knapsack-mysql + +# Static analysis jobs .ruby-static-analysis: &ruby-static-analysis variables: SIMPLECOV: "false" SETUP_DB: "false" - USE_BUNDLE_INSTALL: "true" .rake-exec: &rake-exec <<: *ruby-static-analysis @@ -317,7 +313,7 @@ downtime_check: - master - tags - /^[\d-]+-stable(-ee)?$/ - - /^docs\/*/ + - /(^docs[\/-].*|.*-docs$)/ ee_compat_check: <<: *rake-exec @@ -339,6 +335,7 @@ ee_compat_check: paths: - ee_compat_check/patches/*.patch +# DB migration, rollback, and seed jobs .db-migrate-reset: &db-migrate-reset stage: test <<: *dedicated-runner @@ -346,14 +343,38 @@ ee_compat_check: script: - bundle exec rake db:migrate:reset -rake pg db:migrate:reset: +db:migrate:reset-pg: <<: *db-migrate-reset <<: *use-pg -rake mysql db:migrate:reset: +db:migrate:reset-mysql: <<: *db-migrate-reset <<: *use-mysql +.migration-paths: &migration-paths + stage: test + <<: *dedicated-runner + variables: + SETUP_DB: "false" + <<: *only-canonical-masters + script: + - git fetch origin v8.14.10 + - git checkout -f FETCH_HEAD + - bundle install $BUNDLE_INSTALL_FLAGS + - bundle exec rake db:drop db:create db:schema:load db:seed_fu + - git checkout $CI_COMMIT_SHA + - bundle install $BUNDLE_INSTALL_FLAGS + - . scripts/prepare_build.sh + - bundle exec rake db:migrate + +migration:path-pg: + <<: *migration-paths + <<: *use-pg + +migration:path-mysql: + <<: *migration-paths + <<: *use-mysql + .db-rollback: &db-rollback stage: test <<: *dedicated-runner @@ -362,11 +383,11 @@ rake mysql db:migrate:reset: - bundle exec rake db:rollback STEP=120 - bundle exec rake db:migrate -rake pg db:rollback: +db:rollback-pg: <<: *db-rollback <<: *use-pg -rake mysql db:rollback: +db:rollback-mysql: <<: *db-rollback <<: *use-mysql @@ -388,15 +409,16 @@ rake mysql db:rollback: paths: - log/development.log -rake pg db:seed_fu: +db:seed_fu-pg: <<: *db-seed_fu <<: *use-pg -rake mysql db:seed_fu: +db:seed_fu-mysql: <<: *db-seed_fu <<: *use-mysql -rake gitlab:assets:compile: +# Frontend-related jobs +gitlab:assets:compile: stage: test <<: *dedicated-runner <<: *except-docs @@ -408,15 +430,21 @@ rake gitlab:assets:compile: USE_DB: "false" SKIP_STORAGE_VALIDATION: "true" WEBPACK_REPORT: "true" + NO_COMPRESSION: "true" script: - - bundle exec rake yarn:install gitlab:assets:compile + - yarn install --pure-lockfile --production --cache-folder .yarn-cache + - bundle exec rake gitlab:assets:compile artifacts: name: webpack-report expire_in: 31d paths: - webpack-report/ +<<<<<<< HEAD rake karma: +======= +karma: +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 stage: test <<: *use-pg <<: *dedicated-runner @@ -432,34 +460,6 @@ rake karma: paths: - coverage-javascript/ -.migration-paths: &migration-paths - stage: test - <<: *dedicated-runner - variables: - SETUP_DB: "false" - only: - - master@gitlab-org/gitlab-ce - - master@gitlab-org/gitlab-ee - - master@gitlab/gitlabhq - - master@gitlab/gitlab-ee - script: - - git fetch origin v8.14.10 - - git checkout -f FETCH_HEAD - - bundle install $BUNDLE_INSTALL_FLAGS - - bundle exec rake db:drop db:create db:schema:load db:seed_fu - - git checkout $CI_COMMIT_SHA - - bundle install $BUNDLE_INSTALL_FLAGS - - . scripts/prepare_build.sh - - bundle exec rake db:migrate - -migration pg paths: - <<: *migration-paths - <<: *use-pg - -migration mysql paths: - <<: *migration-paths - <<: *use-mysql - coverage: stage: post-test services: [] @@ -492,33 +492,14 @@ lint:javascript:report: paths: - eslint-report.html -# Trigger docs build -# https://gitlab.com/gitlab-com/doc-gitlab-com/blob/master/README.md#deployment-process -trigger_docs: - stage: post-test - image: "alpine" - <<: *dedicated-runner - before_script: - - apk update && apk add curl - variables: - GIT_STRATEGY: "none" - cache: {} - artifacts: {} - script: - - "HTTP_STATUS=$(curl -X POST -F token=${DOCS_TRIGGER_TOKEN} -F ref=master -F variables[PROJECT]=${CI_PROJECT_NAME} --silent --output curl.log --write-out '%{http_code}' https://gitlab.com/api/v3/projects/1794617/trigger/builds)" - - if [ "${HTTP_STATUS}" -ne "201" ]; then echo "Error ${HTTP_STATUS}"; cat curl.log; echo; exit 1; fi - only: - - master@gitlab-org/gitlab-ce - - master@gitlab-org/gitlab-ee - pages: before_script: [] stage: pages <<: *dedicated-runner dependencies: - coverage - - rake karma - - rake gitlab:assets:compile + - karma + - gitlab:assets:compile - lint:javascript:report script: - mv public/ .public/ diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 66e1e0e20b3..9d53a48409a 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -20,6 +20,12 @@ Please remove this notice if you're confident your issue isn't a duplicate. (How one can reproduce the issue - this is very important) +### Example Project + +(If possible, please create an example project here on GitLab.com that exhibits the problematic behaviour, and link to it here in the bug report) + +(If you are using an older version of GitLab, this will also determine whether the bug has been fixed in a more recent version) + ### What is the current *bug* behavior? (What actually happens) @@ -40,6 +46,7 @@ logs, and code as it's very hard to read otherwise.) #### Results of GitLab environment info <details> +<summary>Expand for output related to GitLab environment info</summary> <pre> (For installations with omnibus-gitlab package run and paste the output of: @@ -54,6 +61,7 @@ logs, and code as it's very hard to read otherwise.) #### Results of GitLab application Check <details> +<summary>Expand for output related to the GitLab application check</summary> <pre> (For installations with omnibus-gitlab package run and paste the output of: diff --git a/.rubocop.yml b/.rubocop.yml index e53af97a92c..8f611a96702 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -390,6 +390,15 @@ Style/OpMethod: Style/ParenthesesAroundCondition: Enabled: true +# This cop (by default) checks for uses of methods Hash#has_key? and +# Hash#has_value? where it enforces Hash#key? and Hash#value? +# It is configurable to enforce the inverse, using `verbose` method +# names also. +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: short, verbose +Style/PreferredHashMethods: + Enabled: true + # Checks for an obsolete RuntimeException argument in raise/fail. Style/RedundantException: Enabled: true @@ -494,7 +503,13 @@ Style/TrailingBlankLines: # This cop checks for trailing comma in array and hash literals. Style/TrailingCommaInLiteral: - Enabled: false + Enabled: true + EnforcedStyleForMultiline: no_comma + +# This cop checks for trailing comma in argument lists. +Style/TrailingCommaInArguments: + Enabled: true + EnforcedStyleForMultiline: no_comma # Checks for %W when interpolation is not needed. Style/UnneededCapitalW: @@ -963,6 +978,12 @@ RSpec/DescribeSymbol: RSpec/DescribedClass: Enabled: true +# Checks if an example group does not include any tests. +RSpec/EmptyExampleGroup: + Enabled: true + CustomIncludeMethods: + - run_permission_checks + # Checks for long example. RSpec/ExampleLength: Enabled: false @@ -981,6 +1002,10 @@ RSpec/ExampleWording: RSpec/ExpectActual: Enabled: true +# Checks for opportunities to use `expect { … }.to output`. +RSpec/ExpectOutput: + Enabled: true + # Checks the file and folder naming of the spec file. RSpec/FilePath: Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 38b22afdf82..e2d9c37479d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -10,11 +10,6 @@ RSpec/BeforeAfterAll: Enabled: false -# Offense count: 15 -# Configuration parameters: CustomIncludeMethods. -RSpec/EmptyExampleGroup: - Enabled: false - # Offense count: 233 RSpec/EmptyLineAfterFinalLet: Enabled: false @@ -23,10 +18,6 @@ RSpec/EmptyLineAfterFinalLet: RSpec/EmptyLineAfterSubject: Enabled: false -# Offense count: 3 -RSpec/ExpectOutput: - Enabled: false - # Offense count: 72 # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: implicit, each, example @@ -245,13 +236,6 @@ Style/PerlBackrefs: Style/PredicateName: Enabled: false -# Offense count: 45 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: short, verbose -Style/PreferredHashMethods: - Enabled: false - # Offense count: 65 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. @@ -369,13 +353,6 @@ Style/SymbolProc: Style/TernaryParentheses: Enabled: false -# Offense count: 53 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyleForMultiline, SupportedStylesForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma -Style/TrailingCommaInArguments: - Enabled: false - # Offense count: 18 # Cop supports --auto-correct. # Configuration parameters: AllowNamedUnderscoreVariables. diff --git a/.scss-lint.yml b/.scss-lint.yml index 83c68309fa8..db234ad739c 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -11,11 +11,11 @@ linters: # !global, !important, and !optional flags. BangFormat: enabled: false - + # Whether or not to prefer `border: 0` over `border: none`. BorderZero: enabled: false - + # Reports when you define a rule set using a selector with chained classes # (a.k.a. adjoining classes). ChainedClasses: @@ -25,13 +25,13 @@ linters: # (e.g. `color: green` is a color keyword) ColorKeyword: enabled: false - + # Prefer color literals (keywords or hexadecimal codes) to be used only in # variable declarations. They should be referred to via variables everywhere # else. ColorVariable: enabled: true - + # Which form of comments to prefer in CSS. Comment: enabled: false @@ -39,7 +39,7 @@ linters: # Reports @debug statements (which you probably left behind accidentally). DebugStatement: enabled: false - + # Rule sets should be ordered as follows: # - @extend declarations # - @include declarations without inner @content @@ -54,19 +54,19 @@ linters: # more information. DisableLinterReason: enabled: true - + # Reports when you define the same property twice in a single rule set. DuplicateProperty: - enabled: false - + enabled: true + # Separate rule, function, and mixin declarations with empty lines. EmptyLineBetweenBlocks: enabled: true - + # Reports when you have an empty rule set. EmptyRule: enabled: true - + # Reports when you have an @extend directive. ExtendDirective: enabled: false @@ -75,49 +75,49 @@ linters: # when adding lines to the file, since SCM systems such as git won't # think that you touched the last line. FinalNewline: - enabled: false - + enabled: true + # HEX colors should use three-character values where possible. HexLength: enabled: false - + # HEX color values should use lower-case colors to differentiate between # letters and numbers, e.g. `#E3E3E3` vs. `#e3e3e3`. HexNotation: enabled: true - + # Avoid using ID selectors. IdSelector: enabled: false - + # The basenames of @imported SCSS partials should not begin with an # underscore and should not include the filename extension. ImportPath: enabled: false - + # Avoid using !important in properties. It is usually indicative of a # misunderstanding of CSS specificity and can lead to brittle code. ImportantRule: enabled: false - + # Indentation should always be done in increments of 2 spaces. Indentation: enabled: true width: 2 - + # Don't write leading zeros for numeric values with a decimal point. LeadingZero: enabled: false - + # Reports when you define the same selector twice in a single sheet. MergeableSelector: enabled: false - + # Functions, mixins, variables, and placeholders should be declared # with all lowercase letters and hyphens instead of underscores. NameFormat: enabled: false - + # Avoid nesting selectors too deeply. NestingDepth: enabled: false @@ -129,12 +129,12 @@ linters: # Sort properties in a strict order. PropertySortOrder: enabled: false - + # Reports when you use an unknown or disabled CSS property # (ignoring vendor-prefixed properties). PropertySpelling: enabled: false - + # Configure which units are allowed for property values. PropertyUnits: enabled: false @@ -144,25 +144,25 @@ linters: # be declared with one colon. PseudoElement: enabled: true - + # Avoid qualifying elements in selectors (also known as "tag-qualifying"). QualifyingElement: enabled: false - + # Don't write selectors with a depth of applicability greater than 3. SelectorDepth: enabled: false - + # Selectors should always use hyphenated-lowercase, rather than camelCase or # snake_case. SelectorFormat: enabled: false convention: hyphenated_lowercase - + # Prefer the shortest shorthand form possible for properties that support it. Shorthand: enabled: true - + # Each property should have its own line, except in the special case of # single line rulesets. SingleLinePerProperty: @@ -173,11 +173,11 @@ linters: # individual selector occupy a single line. SingleLinePerSelector: enabled: true - + # Commas in lists should be followed by a space. SpaceAfterComma: enabled: false - + # Properties should be formatted with a single space separating the colon # from the property's value. SpaceAfterPropertyColon: @@ -197,12 +197,12 @@ linters: # colon. SpaceAfterVariableName: enabled: false - + # Operators should be formatted with a single space on both sides of an # infix operator. SpaceAroundOperator: enabled: true - + # Opening braces should be preceded by a single space. SpaceBeforeBrace: enabled: true @@ -210,7 +210,7 @@ linters: # Parentheses should not be padded with spaces. SpaceBetweenParens: enabled: false - + # Enforces that string literals should be written with a consistent form # of quotes (single or double). StringQuotes: @@ -241,7 +241,7 @@ linters: # be unnecessary. UnnecessaryParentReference: enabled: false - + # URLs should be valid and not contain protocols or domain names. UrlFormat: enabled: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 440e63bc63c..348798b3591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -227,6 +227,34 @@ entry. - Fix preemptive scroll bar on user activity calendar. - Pipeline chat notifications convert seconds to minutes and hours. +<<<<<<< HEAD +======= +## 9.1.7 (2017-06-07) + +- No changes. + +## 9.1.6 (2017-06-02) + +- Fix visibility when referencing snippets. + +## 9.1.5 (2017-05-31) + +- Move uploads from 'public/uploads' to 'public/uploads/system'. +- Restrict API X-Frame-Options to same origin. +- Allow users autocomplete by author_id only for authenticated users. + +## 9.1.4 (2017-05-12) + +- Fix error on CI/CD Settings page related to invalid pipeline trigger. !10948 (dosuken123) +- Sort the network graph both by commit date and topographically. !11057 +- Fix cross referencing for private and internal projects. !11243 +- Handle incoming emails from aliases correctly. +- Gracefully handle failures for incoming emails which do not match on the To header, and have no References header. +- Add missing project attributes to Import/Export. +- Fixed search terms not correctly highlighting. +- Fixed bug where merge request JSON would be displayed. + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 ## 9.1.3 (2017-05-05) - Do not show private groups on subgroups page if user doesn't have access to. @@ -267,6 +295,7 @@ entry. ## 9.1.0 (2017-04-22) +- Add Jupyter notebook rendering !10017 - Added merge requests empty state. !7342 - Add option to start a new resolvable discussion in an MR. !7527 - Hide form inputs for group member without editing rights. !7816 @@ -513,6 +542,20 @@ entry. - Only send chat notifications for the default branch. - Don't fill in the default kubernetes namespace. +## 9.0.10 (2017-06-07) + +- No changes. + +## 9.0.9 (2017-06-02) + +- Fix visibility when referencing snippets. + +## 9.0.8 (2017-05-31) + +- Move uploads from 'public/uploads' to 'public/uploads/system'. +- Restrict API X-Frame-Options to same origin. +- Allow users autocomplete by author_id only for authenticated users. + ## 9.0.7 (2017-05-05) - Enforce project features when searching blobs and wikis. diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 78bc1abd14f..d9df1bbc0c7 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.10.0 +0.11.0 diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index 2b7c5ae0184..17b2ccd9bf9 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -0.4.2 +0.4.3 diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 2d6c0bcf19c..27fabb9b90a 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1,5 @@ +<<<<<<< HEAD 5.0.4 +======= +5.0.5 +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 @@ -97,6 +97,7 @@ gem 'fog-google', '~> 0.5' gem 'fog-local', '~> 0.3' gem 'fog-openstack', '~> 0.1' gem 'fog-rackspace', '~> 0.1.1' +gem 'fog-aliyun', '~> 0.1.0' # for Google storage gem 'google-api-client', '~> 0.8.6' @@ -109,7 +110,7 @@ gem 'seed-fu', '~> 2.3.5' # Markdown and HTML processing gem 'html-pipeline', '~> 1.11.0' -gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie' +gem 'deckar01-task_list', '2.0.0' gem 'gitlab-markup', '~> 1.5.1' gem 'redcarpet', '~> 3.4' gem 'RedCloth', '~> 4.3.2' @@ -145,12 +146,12 @@ gem 'acts-as-taggable-on', '~> 4.0' # Background jobs gem 'sidekiq', '~> 5.0' -gem 'sidekiq-cron', '~> 0.4.4' +gem 'sidekiq-cron', '~> 0.6.0' gem 'redis-namespace', '~> 1.5.2' gem 'sidekiq-limit_fetch', '~> 3.4' # Cron Parser -gem 'rufus-scheduler', '~> 3.1.10' +gem 'rufus-scheduler', '~> 3.4' # HTTP requests gem 'httparty', '~> 0.13.3' @@ -267,6 +268,9 @@ group :metrics do gem 'allocations', '~> 1.0', require: false, platform: :mri gem 'method_source', '~> 0.8', require: false gem 'influxdb', '~> 0.2', require: false + + # Prometheus + gem 'prometheus-client-mmap', '~>0.7.0.beta5' end group :development do @@ -367,6 +371,10 @@ gem 'vmstat', '~> 2.3.0' gem 'sys-filesystem', '~> 1.1.6' # Gitaly GRPC client -gem 'gitaly', '~> 0.6.0' +gem 'gitaly', '~> 0.8.0' gem 'toml-rb', '~> 0.3.15', require: false + +# Feature toggles +gem 'flipper', '~> 0.10.2' +gem 'flipper-active_record', '~> 0.10.2' diff --git a/Gemfile.lock b/Gemfile.lock index c0c56aa9602..b5f9c3beca7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -141,10 +141,8 @@ GEM database_cleaner (1.5.3) debug_inspector (0.0.2) debugger-ruby_core_source (1.3.8) - deckar01-task_list (1.0.6) - activesupport (~> 4.0) + deckar01-task_list (2.0.0) html-pipeline - rack (~> 1.0) default_value_for (3.0.2) activerecord (>= 3.2.0, < 5.1) descendants_tracker (0.0.4) @@ -181,6 +179,8 @@ GEM equalizer (0.0.11) erubis (2.7.0) escape_utils (1.1.1) + et-orbi (1.0.3) + tzinfo eventmachine (1.0.8) excon (0.55.0) execjs (2.6.0) @@ -206,9 +206,18 @@ GEM path_expander (~> 1.0) ruby_parser (~> 3.0) sexp_processor (~> 4.0) + flipper (0.10.2) + flipper-active_record (0.10.2) + activerecord (>= 3.2, < 6) + flipper (~> 0.10.2) flowdock (0.7.1) httparty (~> 0.7) multi_json + fog-aliyun (0.1.0) + fog-core (~> 1.27) + fog-json (~> 1.0) + ipaddress (~> 0.8) + xml-simple (~> 1.1) fog-aws (0.13.0) fog-core (~> 1.38) fog-json (~> 1.0) @@ -263,7 +272,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly (0.6.0) + gitaly (0.8.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -448,6 +457,7 @@ GEM mimemagic (0.3.0) mini_portile2 (2.1.0) minitest (5.7.0) + mmap2 (2.2.6) mousetrap-rails (1.4.6) multi_json (1.12.1) multi_xml (0.6.0) @@ -497,11 +507,10 @@ GEM omniauth (~> 1.0) omniauth-oauth2 (~> 1.0) omniauth-google-oauth2 (0.4.1) - addressable (~> 2.3) - jwt (~> 1.0) + jwt (~> 1.5.2) multi_json (~> 1.3) omniauth (>= 1.1.1) - omniauth-oauth2 (~> 1.3.1) + omniauth-oauth2 (>= 1.3.1) omniauth-kerberos (0.3.0) omniauth-multipassword timfel-krb5-auth (~> 0.8) @@ -552,6 +561,8 @@ GEM premailer-rails (1.9.2) actionmailer (>= 3, < 6) premailer (~> 1.7, >= 1.7.9) + prometheus-client-mmap (0.7.0.beta5) + mmap2 (~> 2.2.6) pry (0.10.4) coderay (~> 1.1.0) method_source (~> 0.8.1) @@ -644,7 +655,7 @@ GEM retriable (1.4.1) rinku (2.0.0) rotp (2.1.2) - rouge (2.0.7) + rouge (2.1.0) rqrcode (0.7.0) chunky_png rqrcode-rails3 (0.1.7) @@ -697,7 +708,8 @@ GEM rubyntlm (0.5.2) rubypants (0.2.0) rubyzip (1.2.1) - rufus-scheduler (3.1.10) + rufus-scheduler (3.4.0) + et-orbi (~> 1.0) rugged (0.25.1.1) safe_yaml (1.0.4) sanitize (2.1.0) @@ -734,9 +746,8 @@ GEM connection_pool (~> 2.2, >= 2.2.0) rack-protection (>= 1.5.0) redis (~> 3.3, >= 3.3.3) - sidekiq-cron (0.4.4) - redis-namespace (>= 1.5.2) - rufus-scheduler (>= 2.0.24) + sidekiq-cron (0.6.0) + rufus-scheduler (>= 3.3.0) sidekiq (>= 4.2.1) sidekiq-limit_fetch (3.4.0) sidekiq (>= 4) @@ -894,7 +905,7 @@ DEPENDENCIES creole (~> 0.5.0) d3_rails (~> 3.5.0) database_cleaner (~> 1.5.0) - deckar01-task_list (= 1.0.6) + deckar01-task_list (= 2.0.0) default_value_for (~> 3.0.0) devise (~> 4.2) devise-two-factor (~> 3.0.0) @@ -908,6 +919,9 @@ DEPENDENCIES faraday (~> 0.11.0) ffaker (~> 2.4) flay (~> 2.8.0) + flipper (~> 0.10.2) + flipper-active_record (~> 0.10.2) + fog-aliyun (~> 0.1.0) fog-aws (~> 0.9) fog-core (~> 1.44) fog-google (~> 0.5) @@ -922,7 +936,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly (~> 0.6.0) + gitaly (~> 0.8.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.5.1) @@ -984,6 +998,7 @@ DEPENDENCIES pg (~> 0.18.2) poltergeist (~> 1.9.0) premailer-rails (~> 1.9.0) + prometheus-client-mmap (~> 0.7.0.beta5) pry-byebug (~> 3.4.1) pry-rails (~> 0.3.4) rack-attack (~> 4.4.1) @@ -1013,7 +1028,7 @@ DEPENDENCIES ruby-fogbugz (~> 0.2.1) ruby-prof (~> 0.16.2) ruby_parser (~> 3.8.4) - rufus-scheduler (~> 3.1.10) + rufus-scheduler (~> 3.4) rugged (~> 0.25.1.1) sanitize (~> 2.0) sass-rails (~> 5.0.6) @@ -1025,7 +1040,7 @@ DEPENDENCIES sham_rack (~> 1.3.6) shoulda-matchers (~> 2.8.0) sidekiq (~> 5.0) - sidekiq-cron (~> 0.4.4) + sidekiq-cron (~> 0.6.0) sidekiq-limit_fetch (~> 3.4) simplecov (~> 0.14.0) slack-notifier (~> 1.5.1) @@ -1058,4 +1073,4 @@ DEPENDENCIES wikicloth (= 0.8.1) BUNDLED WITH - 1.14.6 + 1.15.0 @@ -1 +1,5 @@ +<<<<<<< HEAD 9.3.0-rc1 +======= +9.3.0-pre +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 diff --git a/app/assets/images/i2p-step.svg b/app/assets/images/i2p-step.svg new file mode 100644 index 00000000000..8886092ed82 --- /dev/null +++ b/app/assets/images/i2p-step.svg @@ -0,0 +1,4 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 120" enable-background="new 0 0 12 120"> + <path d="m12 6c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v108.09h2v-108.09c2.833-.479 5-2.943 5-5.91m-6 4c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/> +</svg> diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index d816df831eb..5d060165f4b 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -5,7 +5,8 @@ import Cookies from 'js-cookie'; class Activities { constructor() { - Pager.init(20, true, false, this.updateTooltips); + Pager.init(20, true, false, data => data, this.updateTooltips); + $('.event-filter-link').on('click', (e) => { e.preventDefault(); this.toggleFilter(e.currentTarget); @@ -19,7 +20,7 @@ class Activities { reloadActivities() { $('.content_list').html(''); - Pager.init(20, true, false, this.updateTooltips); + Pager.init(20, true, false, data => data, this.updateTooltips); } toggleFilter(sender) { diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index e5f36c84987..6680834a8d1 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,148 +1,175 @@ -/* eslint-disable func-names, space-before-function-paren, quotes, object-shorthand, camelcase, no-var, comma-dangle, prefer-arrow-callback, quote-props, no-param-reassign, max-len */ - -var Api = { - groupsPath: "/api/:version/groups.json", - groupPath: "/api/:version/groups/:id.json", - namespacesPath: "/api/:version/namespaces.json", - groupProjectsPath: "/api/:version/groups/:id/projects.json", - projectsPath: "/api/:version/projects.json?simple=true", - 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", - dockerfilePath: "/api/:version/templates/dockerfiles/:key", - issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", - group: function(group_id, callback) { - var url = Api.buildUrl(Api.groupPath) - .replace(':id', group_id); +import $ from 'jquery'; + +const Api = { + groupsPath: '/api/:version/groups.json', + groupPath: '/api/:version/groups/:id.json', + namespacesPath: '/api/:version/namespaces.json', + groupProjectsPath: '/api/:version/groups/:id/projects.json', + projectsPath: '/api/:version/projects.json?simple=true', + 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', + dockerfilePath: '/api/:version/templates/dockerfiles/:key', + issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', + usersPath: '/api/:version/users.json', + + group(groupId, callback) { + const url = Api.buildUrl(Api.groupPath) + .replace(':id', groupId); return $.ajax({ - url: url, - dataType: "json" - }).done(function(group) { - return callback(group); - }); + url, + dataType: 'json', + }) + .done(group => callback(group)); }, + // Return groups list. Filtered by query - groups: function(query, options, callback) { - var url = Api.buildUrl(Api.groupsPath); + groups(query, options, callback) { + const url = Api.buildUrl(Api.groupsPath); return $.ajax({ - url: url, - data: $.extend({ + url, + data: Object.assign({ search: query, - per_page: 20 + per_page: 20, }, options), - dataType: "json" - }).done(function(groups) { - return callback(groups); - }); + dataType: 'json', + }) + .done(groups => callback(groups)); }, + // Return namespaces list. Filtered by query - namespaces: function(query, callback) { - var url = Api.buildUrl(Api.namespacesPath); + namespaces(query, callback) { + const url = Api.buildUrl(Api.namespacesPath); return $.ajax({ - url: url, + url, data: { search: query, - per_page: 20 + per_page: 20, }, - dataType: "json" - }).done(function(namespaces) { - return callback(namespaces); - }); + dataType: 'json', + }).done(namespaces => callback(namespaces)); }, + // Return projects list. Filtered by query - projects: function(query, options, callback) { - var url = Api.buildUrl(Api.projectsPath); + projects(query, options, callback) { + const url = Api.buildUrl(Api.projectsPath); return $.ajax({ - url: url, - data: $.extend({ + url, + data: Object.assign({ search: query, per_page: 20, - membership: true + membership: true, }, options), - dataType: "json" - }).done(function(projects) { - return callback(projects); - }); + dataType: 'json', + }) + .done(projects => callback(projects)); }, - newLabel: function(namespace_path, project_path, data, callback) { - var url = Api.buildUrl(Api.labelsPath) - .replace(':namespace_path', namespace_path) - .replace(':project_path', project_path); + + newLabel(namespacePath, projectPath, data, callback) { + const url = Api.buildUrl(Api.labelsPath) + .replace(':namespace_path', namespacePath) + .replace(':project_path', projectPath); return $.ajax({ - url: url, - type: "POST", - data: { 'label': data }, - dataType: "json" - }).done(function(label) { - return callback(label); - }).error(function(message) { - return callback(message.responseJSON); - }); + url, + type: 'POST', + data: { label: data }, + dataType: 'json', + }) + .done(label => callback(label)) + .error(message => callback(message.responseJSON)); }, + // Return group projects list. Filtered by query - groupProjects: function(group_id, query, callback) { - var url = Api.buildUrl(Api.groupProjectsPath) - .replace(':id', group_id); + groupProjects(groupId, query, callback) { + const url = Api.buildUrl(Api.groupProjectsPath) + .replace(':id', groupId); return $.ajax({ - url: url, + url, data: { search: query, - per_page: 20 + per_page: 20, }, - dataType: "json" - }).done(function(projects) { - return callback(projects); - }); + dataType: 'json', + }) + .done(projects => callback(projects)); }, + // Return text for a specific license - licenseText: function(key, data, callback) { - var url = Api.buildUrl(Api.licensePath) + licenseText(key, data, callback) { + const url = Api.buildUrl(Api.licensePath) .replace(':key', key); return $.ajax({ - url: url, - data: data - }).done(function(license) { - return callback(license); - }); + url, + data, + }) + .done(license => callback(license)); }, - gitignoreText: function(key, callback) { - var url = Api.buildUrl(Api.gitignorePath) + + gitignoreText(key, callback) { + const url = Api.buildUrl(Api.gitignorePath) .replace(':key', key); - return $.get(url, function(gitignore) { - return callback(gitignore); - }); + return $.get(url, gitignore => callback(gitignore)); }, - gitlabCiYml: function(key, callback) { - var url = Api.buildUrl(Api.gitlabCiYmlPath) + + gitlabCiYml(key, callback) { + const url = Api.buildUrl(Api.gitlabCiYmlPath) .replace(':key', key); - return $.get(url, function(file) { - return callback(file); - }); + return $.get(url, file => callback(file)); }, - dockerfileYml: function(key, callback) { - var url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); + + dockerfileYml(key, callback) { + const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); $.get(url, callback); }, - issueTemplate: function(namespacePath, projectPath, key, type, callback) { - var url = Api.buildUrl(Api.issuableTemplatePath) + + issueTemplate(namespacePath, projectPath, key, type, callback) { + const 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); + url, + dataType: 'json', + }) + .done(file => callback(null, file)) + .error(callback); }, - buildUrl: function(url) { + + users(query, options) { + const url = Api.buildUrl(this.usersPath); + return Api.wrapAjaxCall({ + url, + data: Object.assign({ + search: query, + per_page: 20, + }, options), + dataType: 'json', + }); + }, + + buildUrl(url) { + let urlRoot = ''; if (gon.relative_url_root != null) { - url = gon.relative_url_root + url; + urlRoot = gon.relative_url_root; } - return url.replace(':version', gon.api_version); - } + return urlRoot + url.replace(':version', gon.api_version); + }, + + wrapAjaxCall(options) { + return new Promise((resolve, reject) => { + // jQuery 2 is not Promises/A+ compatible (missing catch) + $.ajax(options) // eslint-disable-line promise/catch-or-return + .then(data => resolve(data), + (jqXHR, textStatus, errorThrown) => { + const error = new Error(`${options.url}: ${errorThrown}`); + error.textStatus = textStatus; + reject(error); + }, + ); + }); + }, }; -window.Api = Api; +export default Api; diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 23d91fdb259..36ce4fddb72 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -88,6 +88,7 @@ function installGlEmojiElement() { const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0; if ( + emojiUnicode && isEmojiUnicode && !isEmojiUnicodeSupported(generatedUnicodeSupportMap, emojiUnicode, unicodeVersion) ) { diff --git a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js index 20ab2d7e827..4f8884d05ac 100644 --- a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js +++ b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js @@ -28,7 +28,8 @@ function isSkinToneComboEmoji(emojiUnicode) { // doesn't support the skin tone versions of horse racing const horseRacingCodePoint = 127943;// parseInt('1F3C7', 16) function isHorceRacingSkinToneComboEmoji(emojiUnicode) { - return Array.from(emojiUnicode)[0].codePointAt(0) === horseRacingCodePoint && + const firstCharacter = Array.from(emojiUnicode)[0]; + return firstCharacter && firstCharacter.codePointAt(0) === horseRacingCodePoint && isSkinToneComboEmoji(emojiUnicode); } diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js index cdbfe36ca1c..c17877a276d 100644 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -1,5 +1,3 @@ -/* global Flash */ - import sqljs from 'sql.js'; import { template as _template } from 'underscore'; @@ -15,19 +13,27 @@ const PREVIEW_TEMPLATE = _template(` class BalsamiqViewer { constructor(viewer) { this.viewer = viewer; - this.endpoint = this.viewer.dataset.endpoint; } - loadFile() { - const xhr = new XMLHttpRequest(); + loadFile(endpoint) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open('GET', endpoint, true); + xhr.responseType = 'arraybuffer'; + xhr.onload = loadEvent => this.fileLoaded(loadEvent, resolve, reject); + xhr.onerror = reject; + + xhr.send(); + }); + } - xhr.open('GET', this.endpoint, true); - xhr.responseType = 'arraybuffer'; + fileLoaded(loadEvent, resolve, reject) { + if (loadEvent.target.status !== 200) return reject(); - xhr.onload = this.renderFile.bind(this); - xhr.onerror = BalsamiqViewer.onError; + this.renderFile(loadEvent); - xhr.send(); + return resolve(); } renderFile(loadEvent) { @@ -103,12 +109,6 @@ class BalsamiqViewer { static parseTitle(resource) { return JSON.parse(resource.values[0][2]).name; } - - static onError() { - const flash = new Flash('Balsamiq file could not be loaded.'); - - return flash; - } } export default BalsamiqViewer; diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js index 1dacf84470f..8641a6fdae6 100644 --- a/app/assets/javascripts/blob/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -1,6 +1,22 @@ +/* global Flash */ + import BalsamiqViewer from './balsamiq/balsamiq_viewer'; -document.addEventListener('DOMContentLoaded', () => { - const balsamiqViewer = new BalsamiqViewer(document.getElementById('js-balsamiq-viewer')); - balsamiqViewer.loadFile(); -}); +function onError() { + const flash = new window.Flash('Balsamiq file could not be loaded.'); + + return flash; +} + +function loadBalsamiqFile() { + const viewer = document.getElementById('js-balsamiq-viewer'); + + if (!(viewer instanceof Element)) return; + + const endpoint = viewer.dataset.endpoint; + + const balsamiqViewer = new BalsamiqViewer(viewer); + balsamiqViewer.loadFile(endpoint).catch(onError); +} + +$(loadBalsamiqFile); diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index ab5b3751c4e..5ae30990aea 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -1,5 +1,3 @@ -/* global Api */ - export default class FileTemplateSelector { constructor(mediator) { this.mediator = mediator; @@ -65,4 +63,3 @@ export default class FileTemplateSelector { this.reportSelection(opts); } } - diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js index f2f81af137b..9c41e429c8d 100644 --- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js +++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js @@ -1,4 +1,4 @@ -/* global Api */ +import Api from '../../api'; import FileTemplateSelector from '../file_template_selector'; diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js index 3cb7b960aaa..45fb614fe00 100644 --- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js +++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js @@ -1,4 +1,4 @@ -/* global Api */ +import Api from '../../api'; import FileTemplateSelector from '../file_template_selector'; diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js index 7efda8e7f50..a894953cc86 100644 --- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js +++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js @@ -1,4 +1,4 @@ -/* global Api */ +import Api from '../../api'; import FileTemplateSelector from '../file_template_selector'; diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js index 1d757332f6c..b7c4da0f62e 100644 --- a/app/assets/javascripts/blob/template_selectors/license_selector.js +++ b/app/assets/javascripts/blob/template_selectors/license_selector.js @@ -1,4 +1,4 @@ -/* global Api */ +import Api from '../../api'; import FileTemplateSelector from '../file_template_selector'; diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index d06387c0f4d..f770ad2f2ab 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -1,17 +1,38 @@ /* global Flash */ export default class BlobViewer { constructor() { + BlobViewer.initAuxiliaryViewer(); + + this.initMainViewers(); + } + + static initAuxiliaryViewer() { + const auxiliaryViewer = document.querySelector('.blob-viewer[data-type="auxiliary"]'); + if (!auxiliaryViewer) return; + + BlobViewer.loadViewer(auxiliaryViewer); + } + + initMainViewers() { + this.$fileHolder = $('.file-holder'); + if (!this.$fileHolder.length) return; + this.switcher = document.querySelector('.js-blob-viewer-switcher'); this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn'); this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn'); - this.simpleViewer = document.querySelector('.blob-viewer[data-type="simple"]'); - this.richViewer = document.querySelector('.blob-viewer[data-type="rich"]'); - this.$fileHolder = $('.file-holder'); - let initialViewerName = document.querySelector('.blob-viewer:not(.hidden)').getAttribute('data-type'); + this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]'); + this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]'); this.initBindings(); + this.switchToInitialViewer(); + } + + switchToInitialViewer() { + const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)'); + let initialViewerName = initialViewer.getAttribute('data-type'); + if (this.switcher && location.hash.indexOf('#L') === 0) { initialViewerName = 'simple'; } @@ -61,6 +82,7 @@ export default class BlobViewer { $(this.copySourceBtn).tooltip('fixTitle'); } +<<<<<<< HEAD loadViewer(viewerParam) { const viewer = viewerParam; const url = viewer.getAttribute('data-url'); @@ -89,13 +111,15 @@ export default class BlobViewer { }); } +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 switchToViewer(name) { - const newViewer = document.querySelector(`.blob-viewer[data-type='${name}']`); + const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`); if (this.activeViewer === newViewer) return; const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active'); const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`); - const oldViewer = document.querySelector(`.blob-viewer:not([data-type='${name}'])`); + const oldViewer = this.$fileHolder[0].querySelector(`.blob-viewer:not([data-type='${name}'])`); if (oldButton) { oldButton.classList.remove('active'); @@ -116,6 +140,41 @@ export default class BlobViewer { this.toggleCopyButtonState(); - this.loadViewer(newViewer); + BlobViewer.loadViewer(newViewer) + .then((viewer) => { + $(viewer).renderGFM(); + + this.$fileHolder.trigger('highlight:line'); + gl.utils.handleLocationHash(); + + this.toggleCopyButtonState(); + }) + .catch(() => new Flash('Error loading viewer')); + } + + static loadViewer(viewerParam) { + const viewer = viewerParam; + const url = viewer.getAttribute('data-url'); + + return new Promise((resolve, reject) => { + if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) { + resolve(viewer); + return; + } + + viewer.setAttribute('data-loading', 'true'); + + $.ajax({ + url, + dataType: 'JSON', + }) + .fail(reject) + .done((data) => { + viewer.innerHTML = data.html; + viewer.setAttribute('data-loaded', 'true'); + + resolve(viewer); + }); + }); } } diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 88eb4251339..b94009ee76b 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -6,23 +6,22 @@ import Vue from 'vue'; import VueResource from 'vue-resource'; import FilteredSearchBoards from './filtered_search_boards'; import eventHub from './eventhub'; - -require('./models/issue'); -require('./models/label'); -require('./models/list'); -require('./models/milestone'); -require('./models/assignee'); -require('./stores/boards_store'); -require('./stores/modal_store'); -require('./services/board_service'); -require('./mixins/modal_mixins'); -require('./mixins/sortable_default_options'); -require('./filters/due_date_filters'); -require('./components/board'); -require('./components/board_sidebar'); -require('./components/new_list_dropdown'); -require('./components/modal/index'); -require('../vue_shared/vue_resource_interceptor'); +import './models/issue'; +import './models/label'; +import './models/list'; +import './models/milestone'; +import './models/assignee'; +import './stores/boards_store'; +import './stores/modal_store'; +import './services/board_service'; +import './mixins/modal_mixins'; +import './mixins/sortable_default_options'; +import './filters/due_date_filters'; +import './components/board'; +import './components/board_sidebar'; +import './components/new_list_dropdown'; +import './components/modal/index'; +import '../vue_shared/vue_resource_interceptor'; Vue.use(VueResource); @@ -71,6 +70,7 @@ $(() => { gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); this.filterManager = new FilteredSearchBoards(Store.filter, true); + this.filterManager.setup(); // Listen for updateTokens event eventHub.$on('updateTokens', this.updateTokens); @@ -88,6 +88,8 @@ $(() => { if (list.type === 'closed') { list.position = Infinity; list.label = { description: 'Shows all closed issues. Moving an issue to this list closes it' }; + } else if (list.type === 'backlog') { + list.position = -1; } }); @@ -128,7 +130,7 @@ $(() => { }, computed: { disabled() { - return !this.store.lists.filter(list => list.type !== 'blank' && list.type !== 'done').length; + return !this.store.lists.filter(list => !list.preset).length; }, tooltipTitle() { if (this.disabled) { diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 0d23bdeeb99..adb7360327c 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -1,11 +1,10 @@ /* eslint-disable comma-dangle, space-before-function-paren, one-var */ /* global Sortable */ import Vue from 'vue'; +import AccessorUtilities from '../../lib/utils/accessor'; import boardList from './board_list'; import boardBlankState from './board_blank_state'; - -require('./board_delete'); -require('./board_list'); +import './board_delete'; const Store = gl.issueBoards.BoardsStore; @@ -24,6 +23,10 @@ gl.issueBoards.Board = Vue.extend({ disabled: Boolean, issueLinkBase: String, rootPath: String, + boardId: { + type: String, + required: true, + }, }, data () { return { @@ -80,7 +83,16 @@ gl.issueBoards.Board = Vue.extend({ methods: { showNewIssueForm() { this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; - } + }, + toggleExpanded(e) { + if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) { + this.list.isExpanded = !this.list.isExpanded; + + if (AccessorUtilities.isLocalStorageAccessSafe()) { + localStorage.setItem(`boards.${this.boardId}.${this.list.type}.expanded`, this.list.isExpanded); + } + } + }, }, mounted () { this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({ @@ -104,4 +116,11 @@ gl.issueBoards.Board = Vue.extend({ this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); }, + created() { + if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) { + const isCollapsed = localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false'; + + this.list.isExpanded = !isCollapsed; + } + }, }); diff --git a/app/assets/javascripts/boards/components/board_card.js b/app/assets/javascripts/boards/components/board_card.js index f591134c548..079fb6438b9 100644 --- a/app/assets/javascripts/boards/components/board_card.js +++ b/app/assets/javascripts/boards/components/board_card.js @@ -1,4 +1,4 @@ -require('./issue_card_inner'); +import './issue_card_inner'; const Store = gl.issueBoards.BoardsStore; diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js index 49a775002c3..bebca17fb1e 100644 --- a/app/assets/javascripts/boards/components/board_list.js +++ b/app/assets/javascripts/boards/components/board_list.js @@ -2,6 +2,7 @@ import boardNewIssue from './board_new_issue'; import boardCard from './board_card'; import eventHub from '../eventhub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; const Store = gl.issueBoards.BoardsStore; @@ -44,6 +45,7 @@ export default { components: { boardCard, boardNewIssue, + loadingIcon, }, methods: { listHeight() { @@ -55,6 +57,9 @@ export default { scrollTop() { return this.$refs.list.scrollTop + this.listHeight(); }, + scrollToTop() { + this.$refs.list.scrollTop = 0; + }, loadNextPage() { const getIssues = this.list.nextPage(); const loadingDone = () => { @@ -106,6 +111,7 @@ export default { }, created() { eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, mounted() { const options = gl.issueBoards.getBoardSortableDefaultOptions({ @@ -148,6 +154,7 @@ export default { }, beforeDestroy() { eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); this.$refs.list.removeEventListener('scroll', this.onScroll); }, template: ` @@ -156,14 +163,13 @@ export default { class="board-list-loading text-center" aria-label="Loading issues" v-if="loading"> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true"> - </i> + <loading-icon /> </div> - <board-new-issue - :list="list" - v-if="list.type !== 'closed' && showIssueForm"/> + <transition name="slide-down"> + <board-new-issue + :list="list" + v-if="list.type !== 'closed' && showIssueForm"/> + </transition> <ul class="board-list" v-show="!loading" @@ -184,12 +190,12 @@ export default { class="board-list-count text-center" v-if="showCount" data-id="-1"> - <i - class="fa fa-spinner fa-spin" - aria-label="Loading more issues" - aria-hidden="true" - v-show="list.loadingMore"> - </i> + + <loading-icon + v-show="list.loadingMore" + label="Loading more issues" + /> + <span v-if="list.issues.length === list.issuesSize"> Showing all issues </span> diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js index 1ce95b62138..b1c47b09c35 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js +++ b/app/assets/javascripts/boards/components/board_new_issue.js @@ -48,6 +48,7 @@ export default { this.error = true; }); + eventHub.$emit(`scroll-board-list-${this.list.id}`); this.cancel(); }, cancel() { @@ -75,6 +76,7 @@ export default { type="text" v-model="title" ref="input" + autocomplete="off" :id="list.id + '-title'" /> <div class="clearfix prepend-top-10"> <button class="btn btn-success pull-left" diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 60b58b6fd41..386102032cb 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -7,11 +7,9 @@ import Vue from 'vue'; import eventHub from '../../sidebar/event_hub'; - import AssigneeTitle from '../../sidebar/components/assignees/assignee_title'; import Assignees from '../../sidebar/components/assignees/assignees'; - -require('./sidebar/remove_issue'); +import './sidebar/remove_issue'; const Store = gl.issueBoards.BoardsStore; @@ -36,6 +34,9 @@ gl.issueBoards.BoardSidebar = Vue.extend({ }, assigneeId() { return this.issue.assignee ? this.issue.assignee.id : 0; + }, + milestoneTitle() { + return this.issue.milestone ? this.issue.milestone.title : 'No Milestone'; } }, watch: { @@ -62,18 +63,6 @@ gl.issueBoards.BoardSidebar = Vue.extend({ }, deep: true }, - issue () { - if (this.showSidebar) { - this.$nextTick(() => { - $('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0); - $('.right-sidebar').getNiceScroll().resize(); - }); - } - - this.issue = this.detail.issue; - this.list = this.detail.list; - }, - deep: true }, methods: { closeSidebar () { diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index 710207db0c7..daef01bc93d 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '../eventhub'; const Store = gl.issueBoards.BoardsStore; @@ -38,6 +39,9 @@ gl.issueBoards.IssueCardInner = Vue.extend({ maxCounter: 99, }; }, + components: { + userAvatarLink, + }, computed: { numberOverLimit() { return this.issue.assignees.length - this.limitBeforeCounter; @@ -146,23 +150,17 @@ gl.issueBoards.IssueCardInner = Vue.extend({ </span> </h4> <div class="card-assignee"> - <a - class="has-tooltip js-no-trigger" - :href="assigneeUrl(assignee)" - :title="assigneeUrlTitle(assignee)" + <user-avatar-link v-for="(assignee, index) in issue.assignees" + :key="assignee.id" v-if="shouldRenderAssignee(index)" - data-container="body" - data-placement="bottom" - > - <img - class="avatar avatar-inline s20" - :src="assignee.avatar" - width="20" - height="20" - :alt="avatarUrlTitle(assignee)" - /> - </a> + class="js-no-trigger" + :link-href="assigneeUrl(assignee)" + :img-alt="avatarUrlTitle(assignee)" + :img-src="assignee.avatar" + :tooltip-text="assigneeUrlTitle(assignee)" + tooltip-placement="bottom" + /> <span class="avatar-counter has-tooltip" :title="assigneeCounterTooltip" diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js index b214b5a7199..56a0fde5a91 100644 --- a/app/assets/javascripts/boards/components/modal/filters.js +++ b/app/assets/javascripts/boards/components/modal/filters.js @@ -13,6 +13,7 @@ export default { FilteredSearchContainer.container = this.$el; this.filteredSearch = new FilteredSearchBoards(this.store); + this.filteredSearch.setup(); this.filteredSearch.removeTokens(); this.filteredSearch.handleInputPlaceholder(); this.filteredSearch.toggleClearSearchButton(); diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js index ccd270b27da..478a1335b2b 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.js @@ -2,8 +2,7 @@ /* global Flash */ import Vue from 'vue'; - -require('./lists_dropdown'); +import './lists_dropdown'; const ModalStore = gl.issueBoards.ModalStore; @@ -27,7 +26,8 @@ gl.issueBoards.ModalFooter = Vue.extend({ }, methods: { addIssues() { - const list = this.modal.selectedList || this.state.lists[0]; + const firstListIndex = 1; + const list = this.modal.selectedList || this.state.lists[firstListIndex]; const selectedIssues = ModalStore.getSelectedIssues(); const issueIds = selectedIssues.map(issue => issue.globalId); diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js index e2b3f9ae7e2..31f59d295bf 100644 --- a/app/assets/javascripts/boards/components/modal/header.js +++ b/app/assets/javascripts/boards/components/modal/header.js @@ -1,7 +1,6 @@ import Vue from 'vue'; import modalFilters from './filters'; - -require('./tabs'); +import './tabs'; const ModalStore = gl.issueBoards.ModalStore; diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js index a61cc7954a1..6356c266ee2 100644 --- a/app/assets/javascripts/boards/components/modal/index.js +++ b/app/assets/javascripts/boards/components/modal/index.js @@ -2,11 +2,11 @@ import Vue from 'vue'; import queryData from '../../utils/query_data'; - -require('./header'); -require('./list'); -require('./footer'); -require('./empty_state'); +import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; +import './header'; +import './list'; +import './footer'; +import './empty_state'; const ModalStore = gl.issueBoards.ModalStore; @@ -137,6 +137,7 @@ gl.issueBoards.IssuesModal = Vue.extend({ 'modal-list': gl.issueBoards.ModalList, 'modal-footer': gl.issueBoards.ModalFooter, 'empty-state': gl.issueBoards.ModalEmptyState, + loadingIcon, }, template: ` <div @@ -161,7 +162,7 @@ gl.issueBoards.IssuesModal = Vue.extend({ class="add-issues-list text-center" v-if="loading || filterLoading"> <div class="add-issues-list-loading"> - <i class="fa fa-spinner fa-spin"></i> + <loading-icon /> </div> </section> <modal-footer></modal-footer> diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js index 8cd15df90fa..4684ea76647 100644 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js @@ -11,7 +11,7 @@ gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ }, computed: { selected() { - return this.modal.selectedList || this.state.lists[0]; + return this.modal.selectedList || this.state.lists[1]; }, }, destroyed() { diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 1264280284c..b37698fe9ca 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -2,7 +2,7 @@ import FilteredSearchContainer from '../filtered_search/container'; export default class FilteredSearchBoards extends gl.FilteredSearchManager { - constructor(store, updateUrl = false) { + constructor(store, updateUrl = false, cantEdit = []) { super('boards'); this.store = store; @@ -11,6 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { // Issue boards is slightly different, we handle all the requests async // instead or reloading the page, we just re-fire the list ajax requests this.isHandledAsync = true; + + this.cantEdit = cantEdit; } updateObject(path) { @@ -40,4 +42,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { // Get the placeholder back if search is empty this.filteredSearchInput.dispatchEvent(new Event('input')); } + + canEdit(tokenName) { + return this.cantEdit.indexOf(tokenName) === -1; + } } diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 90561d0f7a8..548de1a4c52 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -12,7 +12,9 @@ class List { this.position = obj.position; this.title = obj.title; this.type = obj.list_type; - this.preset = ['closed', 'blank'].indexOf(this.type) > -1; + this.preset = ['backlog', 'closed', 'blank'].indexOf(this.type) > -1; + this.isExpandable = ['backlog', 'closed'].indexOf(this.type) > -1; + this.isExpanded = true; this.page = 1; this.loading = true; this.loadingMore = false; @@ -103,13 +105,19 @@ class List { } newIssue (issue) { - this.addIssue(issue); + this.addIssue(issue, null, 0); this.issuesSize += 1; return gl.boardService.newIssue(this.id, issue) .then((resp) => { const data = resp.json(); issue.id = data.iid; + }) + .then(() => { + if (this.issuesSize > 1) { + const moveBeforeIid = this.issues[1].id; + gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid); + } }); } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index ad9997ac334..1e12d4ca415 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -22,6 +22,7 @@ gl.issueBoards.BoardsStore = { create () { this.state.lists = []; this.filter.path = gl.utils.getUrlParamsArray().join('&'); + this.detail = { issue: {} }; }, addList (listObj, defaultAvatar) { const list = new List(listObj, defaultAvatar); @@ -31,10 +32,14 @@ gl.issueBoards.BoardsStore = { }, new (listObj) { const list = this.addList(listObj); + const 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.state.lists = _.sortBy(this.state.lists, 'position'); }) .catch(() => { @@ -47,7 +52,7 @@ gl.issueBoards.BoardsStore = { }, shouldAddBlankState () { // Decide whether to add the blank state - return !(this.state.lists.filter(list => list.type !== 'closed')[0]); + return !(this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0]); }, addBlankState () { if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; @@ -100,7 +105,7 @@ gl.issueBoards.BoardsStore = { issueTo.removeLabel(listFrom.label); } - if (listTo.type === 'closed') { + if (listTo.type === 'closed' && listFrom.type !== 'backlog') { issueLists.forEach((list) => { list.removeIssue(issue); }); diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js new file mode 100644 index 00000000000..af8bcdc1794 --- /dev/null +++ b/app/assets/javascripts/branches/branches_delete_modal.js @@ -0,0 +1,36 @@ +const MODAL_SELECTOR = '#modal-delete-branch'; + +class DeleteModal { + constructor() { + this.$modal = $(MODAL_SELECTOR); + this.$toggleBtns = $(`[data-target="${MODAL_SELECTOR}"]`); + this.$branchName = $('.js-branch-name', this.$modal); + this.$confirmInput = $('.js-delete-branch-input', this.$modal); + this.$deleteBtn = $('.js-delete-branch', this.$modal); + this.bindEvents(); + } + + bindEvents() { + this.$toggleBtns.on('click', this.setModalData.bind(this)); + this.$confirmInput.on('input', this.setDeleteDisabled.bind(this)); + } + + setModalData(e) { + this.branchName = e.currentTarget.dataset.branchName || ''; + this.deletePath = e.currentTarget.dataset.deletePath || ''; + this.updateModal(); + } + + setDeleteDisabled(e) { + this.$deleteBtn.attr('disabled', e.currentTarget.value !== this.branchName); + } + + updateModal() { + this.$branchName.text(this.branchName); + this.$confirmInput.val(''); + this.$deleteBtn.attr('href', this.deletePath); + this.$deleteBtn.attr('disabled', true); + } +} + +export default DeleteModal; diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 97f279e4be4..072a899e9f2 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -2,15 +2,11 @@ consistent-return, prefer-rest-params */ /* global Breakpoints */ +import _ from 'underscore'; import { bytesToKiB } from './lib/utils/number_utils'; -const bind = function (fn, me) { return function () { return fn.apply(me, arguments); }; }; -const AUTO_SCROLL_OFFSET = 75; -const DOWN_BUILD_TRACE = '#down-build-trace'; - window.Build = (function () { Build.timeout = null; - Build.state = null; function Build(options) { @@ -23,21 +19,22 @@ window.Build = (function () { this.buildStage = this.options.buildStage; this.$document = $(document); this.logBytes = 0; + this.scrollOffsetPadding = 30; - this.updateDropdown = bind(this.updateDropdown, this); + this.updateDropdown = this.updateDropdown.bind(this); + this.getBuildTrace = this.getBuildTrace.bind(this); + this.scrollToBottom = this.scrollToBottom.bind(this); this.$body = $('body'); this.$buildTrace = $('#build-trace'); - this.$autoScrollContainer = $('.autoscroll-container'); - this.$autoScrollStatus = $('#autoscroll-status'); - this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text'); - this.$upBuildTrace = $('#up-build-trace'); - this.$downBuildTrace = $(DOWN_BUILD_TRACE); - this.$scrollTopBtn = $('#scroll-top'); - this.$scrollBottomBtn = $('#scroll-bottom'); this.$buildRefreshAnimation = $('.js-build-refresh'); - this.$buildScroll = $('#js-build-scroll'); this.$truncatedInfo = $('.js-truncated-info'); + this.$buildTraceOutput = $('.js-build-output'); + this.$scrollContainer = $('.js-scroll-container'); + + // Scroll controllers + this.$scrollTopBtn = $('.js-scroll-up'); + this.$scrollBottomBtn = $('.js-scroll-down'); clearTimeout(Build.timeout); // Init breakpoint checker @@ -56,54 +53,149 @@ window.Build = (function () { .off('click', '.stage-item') .on('click', '.stage-item', this.updateDropdown); - this.$document.on('scroll', this.initScrollMonitor.bind(this)); + // add event listeners to the scroll buttons + this.$scrollTopBtn + .off('click') + .on('click', this.scrollToTop.bind(this)); + + this.$scrollBottomBtn + .off('click') + .on('click', this.scrollToBottom.bind(this)); $(window) .off('resize.build') - .on('resize.build', this.sidebarOnResize.bind(this)); - - $('a', this.$buildScroll) - .off('click.stepTrace') - .on('click.stepTrace', this.stepTrace); + .on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100)); this.updateArtifactRemoveDate(); - this.initScrollButtonAffix(); - this.invokeBuildTrace(); + + // eslint-disable-next-line + this.getBuildTrace() + .then(() => this.makeTraceScrollable()) + .then(() => this.scrollToBottom()); + + this.verifyTopPosition(); } + Build.prototype.makeTraceScrollable = function () { + this.$scrollContainer.niceScroll({ + cursorcolor: '#fff', + cursoropacitymin: 1, + cursorwidth: '3px', + railpadding: { top: 5, bottom: 5, right: 5 }, + }); + + this.$scrollContainer.on('scroll', _.throttle(this.toggleScroll.bind(this), 100)); + + this.toggleScroll(); + }; + + Build.prototype.canScroll = function () { + return (this.$scrollContainer.prop('scrollHeight') - this.scrollOffsetPadding) > this.$scrollContainer.height(); + }; + + /** + * | | Up | Down | + * |--------------------------|----------|----------| + * | on scroll bottom | active | disabled | + * | on scroll top | disabled | active | + * | no scroll | disabled | disabled | + * | on.('scroll') is on top | disabled | active | + * | on('scroll) is on bottom | active | disabled | + * + */ + Build.prototype.toggleScroll = function () { + const bottomScroll = this.$scrollContainer.scrollTop() + + this.scrollOffsetPadding + + this.$scrollContainer.height(); + + if (this.canScroll()) { + if (this.$scrollContainer.scrollTop() === 0) { + this.toggleDisableButton(this.$scrollTopBtn, true); + this.toggleDisableButton(this.$scrollBottomBtn, false); + } else if (bottomScroll === this.$scrollContainer.prop('scrollHeight')) { + this.toggleDisableButton(this.$scrollTopBtn, false); + this.toggleDisableButton(this.$scrollBottomBtn, true); + } else { + this.toggleDisableButton(this.$scrollTopBtn, false); + this.toggleDisableButton(this.$scrollBottomBtn, false); + } + } + }; + + Build.prototype.scrollToTop = function () { + this.$scrollContainer.getNiceScroll(0).doScrollTop(0); + this.toggleScroll(); + }; + + Build.prototype.scrollToBottom = function () { + this.$scrollContainer.getNiceScroll(0).doScrollTo(this.$scrollContainer.prop('scrollHeight')); + this.toggleScroll(); + }; + + Build.prototype.toggleDisableButton = function ($button, disable) { + if (disable && $button.prop('disabled')) return; + $button.prop('disabled', disable); + }; + + Build.prototype.toggleScrollAnimation = function (toggle) { + this.$scrollBottomBtn.toggleClass('animate', toggle); + }; + + /** + * Build trace top position depends on the space ocupied by the elments rendered before + */ + Build.prototype.verifyTopPosition = function () { + const $buildPage = $('.build-page'); + + const $header = $('.build-header', $buildPage); + const $runnersStuck = $('.js-build-stuck', $buildPage); + const $startsEnvironment = $('.js-environment-container', $buildPage); + const $erased = $('.js-build-erased', $buildPage); + + let topPostion = 168; + + if ($header) { + topPostion += $header.outerHeight(); + } + + if ($runnersStuck) { + topPostion += $runnersStuck.outerHeight(); + } + + if ($startsEnvironment) { + topPostion += $startsEnvironment.outerHeight(); + } + + if ($erased) { + topPostion += $erased.outerHeight() + 10; + } + + this.$buildTrace.css({ + top: topPostion, + }); + }; + Build.prototype.initSidebar = function () { this.$sidebar = $('.js-build-sidebar'); this.$sidebar.niceScroll(); - this.$document - .off('click', '.js-sidebar-build-toggle') - .on('click', '.js-sidebar-build-toggle', this.toggleSidebar); - }; - - Build.prototype.invokeBuildTrace = function () { - return this.getBuildTrace(); }; Build.prototype.getBuildTrace = function () { return $.ajax({ url: `${this.pageUrl}/trace.json`, - dataType: 'json', - data: { - state: this.state, - }, - success: ((log) => { - const $buildContainer = $('.js-build-output'); - + data: this.state, + }) + .done((log) => { gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`); - if (log.state) { this.state = log.state; } if (log.append) { - $buildContainer.append(log.html); + this.$buildTraceOutput.append(log.html); this.logBytes += log.size; } else { - $buildContainer.html(log.html); + this.$buildTraceOutput.html(log.html); this.logBytes = log.size; } @@ -114,141 +206,30 @@ window.Build = (function () { const size = bytesToKiB(this.logBytes); $('.js-truncated-info-size').html(`${size}`); this.$truncatedInfo.removeClass('hidden'); - this.initAffixTruncatedInfo(); } else { this.$truncatedInfo.addClass('hidden'); } - this.checkAutoscroll(); - if (!log.complete) { + this.toggleScrollAnimation(true); + Build.timeout = setTimeout(() => { - this.invokeBuildTrace(); + //eslint-disable-next-line + this.getBuildTrace() + .then(() => this.scrollToBottom()); }, 4000); } else { this.$buildRefreshAnimation.remove(); + this.toggleScrollAnimation(false); } if (log.status !== this.buildStatus) { - let pageUrl = this.pageUrl; - - if (this.$autoScrollStatus.data('state') === 'enabled') { - pageUrl += DOWN_BUILD_TRACE; - } - - gl.utils.visitUrl(pageUrl); + gl.utils.visitUrl(this.pageUrl); } - }), - error: () => { + }) + .fail(() => { this.$buildRefreshAnimation.remove(); - return this.initScrollMonitor(); - }, - }); - }; - - Build.prototype.checkAutoscroll = function () { - if (this.$autoScrollStatus.data('state') === 'enabled') { - return $('html,body').scrollTop(this.$buildTrace.height()); - } - - // Handle a situation where user started new build - // but never scrolled a page - if (!this.$scrollTopBtn.is(':visible') && - !this.$scrollBottomBtn.is(':visible') && - !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - this.$scrollBottomBtn.show(); - } - }; - - Build.prototype.initScrollButtonAffix = function () { - // Hide everything initially - this.$scrollTopBtn.hide(); - this.$scrollBottomBtn.hide(); - this.$autoScrollContainer.hide(); - }; - - // Page scroll listener to detect if user has scrolling page - // and handle following cases - // 1) User is at Top of Build Log; - // - Hide Top Arrow button - // - Show Bottom Arrow button - // - Disable Autoscroll and hide indicator (when build is running) - // 2) User is at Bottom of Build Log; - // - Show Top Arrow button - // - Hide Bottom Arrow button - // - Enable Autoscroll and show indicator (when build is running) - // 3) User is somewhere in middle of Build Log; - // - Show Top Arrow button - // - Show Bottom Arrow button - // - Disable Autoscroll and hide indicator (when build is running) - Build.prototype.initScrollMonitor = function () { - if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && - !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - // User is somewhere in middle of Build Log - - this.$scrollTopBtn.show(); - - if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed - this.$scrollBottomBtn.show(); - } else if (this.$buildRefreshAnimation.is(':visible') && - !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) { - this.$scrollBottomBtn.show(); - } else { - this.$scrollBottomBtn.hide(); - } - - // Hide Autoscroll Status Indicator - if (this.$scrollBottomBtn.is(':visible')) { - this.$autoScrollContainer.hide(); - this.$autoScrollStatusText.removeClass('animate'); - } else { - this.$autoScrollContainer.css({ - top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET, - }).show(); - this.$autoScrollStatusText.addClass('animate'); - } - } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && - !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - // User is at Top of Build Log - - this.$scrollTopBtn.hide(); - this.$scrollBottomBtn.show(); - - this.$autoScrollContainer.hide(); - this.$autoScrollStatusText.removeClass('animate'); - } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && - gl.utils.isInViewport(this.$downBuildTrace.get(0))) || - (this.$buildRefreshAnimation.is(':visible') && - gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) { - // User is at Bottom of Build Log - - this.$scrollTopBtn.show(); - this.$scrollBottomBtn.hide(); - - // Show and Reposition Autoscroll Status Indicator - this.$autoScrollContainer.css({ - top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET, - }).show(); - this.$autoScrollStatusText.addClass('animate'); - } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && - gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - // Build Log height is small - - this.$scrollTopBtn.hide(); - this.$scrollBottomBtn.hide(); - - // Hide Autoscroll Status Indicator - this.$autoScrollContainer.hide(); - this.$autoScrollStatusText.removeClass('animate'); - } - - if (this.buildStatus === 'running' || this.buildStatus === 'pending') { - // Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise. - this.$autoScrollStatus.data( - 'state', - gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled', - ); - } + }); }; Build.prototype.shouldHideSidebarForViewport = function () { @@ -257,18 +238,24 @@ window.Build = (function () { }; Build.prototype.toggleSidebar = function (shouldHide) { - const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; + const shouldShow = !shouldHide; - this.$buildScroll.toggleClass('sidebar-expanded', shouldShow) - .toggleClass('sidebar-collapsed', shouldHide); - this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow) + this.$buildTrace + .toggleClass('sidebar-expanded', shouldShow) .toggleClass('sidebar-collapsed', shouldHide); - this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow) + this.$sidebar + .toggleClass('right-sidebar-expanded', shouldShow) .toggleClass('right-sidebar-collapsed', shouldHide); }; Build.prototype.sidebarOnResize = function () { this.toggleSidebar(this.shouldHideSidebarForViewport()); + + this.verifyTopPosition(); + + if (this.$scrollContainer.getNiceScroll(0)) { + this.toggleScroll(); + } }; Build.prototype.sidebarOnClick = function () { @@ -301,24 +288,5 @@ window.Build = (function () { this.populateJobs(stage); }; - Build.prototype.stepTrace = function (e) { - e.preventDefault(); - - const $currentTarget = $(e.currentTarget); - $.scrollTo($currentTarget.attr('href'), { - offset: 0, - }); - }; - - Build.prototype.initAffixTruncatedInfo = function () { - const offsetTop = this.$buildTrace.offset().top; - - this.$truncatedInfo.affix({ - offset: { - top: offsetTop, - }, - }); - }; - return Build; })(); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js index ad9c600b499..082fbafb740 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -1,11 +1,12 @@ import Vue from 'vue'; import Visibility from 'visibilityjs'; -import PipelinesTableComponent from '../../vue_shared/components/pipelines_table'; +import pipelinesTableComponent from '../../vue_shared/components/pipelines_table'; import PipelinesService from '../../pipelines/services/pipelines_service'; import PipelineStore from '../../pipelines/stores/pipelines_store'; import eventHub from '../../pipelines/event_hub'; -import EmptyState from '../../pipelines/components/empty_state.vue'; -import ErrorState from '../../pipelines/components/error_state.vue'; +import emptyState from '../../pipelines/components/empty_state.vue'; +import errorState from '../../pipelines/components/error_state.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import '../../lib/utils/common_utils'; import '../../vue_shared/vue_resource_interceptor'; import Poll from '../../lib/utils/poll'; @@ -17,16 +18,15 @@ import Poll from '../../lib/utils/poll'; * We need a store to store the received environemnts. * We need a service to communicate with the server. * - * Necessary SVG in the table are provided as props. This should be refactored - * as soon as we have Webpack and can load them directly into JS files. */ export default Vue.component('pipelines-table', { components: { - 'pipelines-table-component': PipelinesTableComponent, - 'error-state': ErrorState, - 'empty-state': EmptyState, + pipelinesTableComponent, + errorState, + emptyState, + loadingIcon, }, /** @@ -47,6 +47,7 @@ export default Vue.component('pipelines-table', { hasError: false, isMakingRequest: false, updateGraphDropdown: false, + hasMadeRequest: false, }; }, @@ -55,9 +56,15 @@ export default Vue.component('pipelines-table', { return this.hasError && !this.isLoading; }, + /** + * Empty state is only rendered if after the first request we receive no pipelines. + * + * @return {Boolean} + */ shouldRenderEmptyState() { return !this.state.pipelines.length && !this.isLoading && + this.hasMadeRequest && !this.hasError; }, @@ -94,6 +101,10 @@ export default Vue.component('pipelines-table', { if (!Visibility.hidden()) { this.isLoading = true; this.poll.makeRequest(); + } else { + // If tab is not visible we need to make the first request so we don't show the empty + // state without knowing if there are any pipelines + this.fetchPipelines(); } Visibility.change(() => { @@ -107,7 +118,7 @@ export default Vue.component('pipelines-table', { eventHub.$on('refreshPipelines', this.fetchPipelines); }, - beforeDestroyed() { + beforeDestroy() { eventHub.$off('refreshPipelines'); }, @@ -127,6 +138,8 @@ export default Vue.component('pipelines-table', { successCallback(resp) { const response = resp.json(); + this.hasMadeRequest = true; + // depending of the endpoint the response can either bring a `pipelines` key or not. const pipelines = response.pipelines || response; this.store.storePipelines(pipelines); @@ -151,13 +164,12 @@ export default Vue.component('pipelines-table', { template: ` <div class="content-list pipelines"> - <div - class="realtime-loading" - v-if="isLoading"> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true" /> - </div> + + <loading-icon + label="Loading pipelines" + size="3" + v-if="isLoading" + /> <empty-state v-if="shouldRenderEmptyState" diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index e3f9eaaf39c..2b0bf49cf92 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -7,6 +7,8 @@ window.CommitsList = (function() { CommitsList.timer = null; CommitsList.init = function(limit) { + this.$contentList = $('.content_list'); + $("body").on("click", ".day-commits-table li.commit", function(e) { if (e.target.nodeName !== "A") { location.href = $(this).attr("url"); @@ -14,9 +16,9 @@ window.CommitsList = (function() { return false; } }); - Pager.init(limit, false, false, function() { - gl.utils.localTimeAgo($('.js-timeago')); - }); + + Pager.init(limit, false, false, this.processCommits); + this.content = $("#commits-list"); this.searchField = $("#commits-search"); this.lastSearch = this.searchField.val(); @@ -62,5 +64,34 @@ window.CommitsList = (function() { }); }; + // Prepare loaded data. + CommitsList.processCommits = (data) => { + let processedData = data; + const $processedData = $(processedData); + const $commitsHeadersLast = CommitsList.$contentList.find('li.js-commit-header').last(); + const lastShownDay = $commitsHeadersLast.data('day'); + const $loadedCommitsHeadersFirst = $processedData.filter('li.js-commit-header').first(); + const loadedShownDayFirst = $loadedCommitsHeadersFirst.data('day'); + let commitsCount; + + // If commits headers show the same date, + // remove the last header and change the previous one. + if (lastShownDay === loadedShownDayFirst) { + // Last shown commits count under the last commits header. + commitsCount = $commitsHeadersLast.nextUntil('li.js-commit-header').find('li.commit').length; + + // Remove duplicate of commits header. + processedData = $processedData.not(`li.js-commit-header[data-day="${loadedShownDayFirst}"]`); + + // Update commits count in the previous commits header. + commitsCount += Number($(processedData).nextUntil('li.js-commit-header').first().find('li.commit').length); + $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${gl.text.pluralize('commit', commitsCount)}`); + } + + gl.utils.localTimeAgo($processedData.find('.js-timeago')); + + return processedData; + }; + return CommitsList; })(); diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js index 570799c030e..ba9d9a3e1f7 100644 --- a/app/assets/javascripts/copy_as_gfm.js +++ b/app/assets/javascripts/copy_as_gfm.js @@ -1,6 +1,6 @@ /* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */ -require('./lib/utils/common_utils'); +import './lib/utils/common_utils'; const gfmRules = { // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert @@ -18,12 +18,12 @@ const gfmRules = { }, }, TaskListFilter: { - 'input[type=checkbox].task-list-item-checkbox'(el, text) { + 'input[type=checkbox].task-list-item-checkbox'(el) { return `[${el.checked ? 'x' : ' '}]`; }, }, ReferenceFilter: { - '.tooltip'(el, text) { + '.tooltip'(el) { return ''; }, 'a.gfm:not([data-link=true])'(el, text) { @@ -39,15 +39,15 @@ const gfmRules = { }, }, TableOfContentsFilter: { - 'ul.section-nav'(el, text) { + 'ul.section-nav'(el) { return '[[_TOC_]]'; }, }, EmojiFilter: { - 'img.emoji'(el, text) { + 'img.emoji'(el) { return el.getAttribute('alt'); }, - 'gl-emoji'(el, text) { + 'gl-emoji'(el) { return `:${el.getAttribute('data-name')}:`; }, }, @@ -57,13 +57,13 @@ const gfmRules = { }, }, VideoLinkFilter: { - '.video-container'(el, text) { + '.video-container'(el) { const videoEl = el.querySelector('video'); if (!videoEl) return false; return CopyAsGFM.nodeToGFM(videoEl); }, - 'video'(el, text) { + 'video'(el) { return `})`; }, }, @@ -74,19 +74,19 @@ const gfmRules = { 'code.code.math[data-math-style=inline]'(el, text) { return `$\`${text}\`$`; }, - 'span.katex-display span.katex-mathml'(el, text) { + 'span.katex-display span.katex-mathml'(el) { const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); if (!mathAnnotation) return false; return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; }, - 'span.katex-mathml'(el, text) { + 'span.katex-mathml'(el) { const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); if (!mathAnnotation) return false; return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; }, - 'span.katex-html'(el, text) { + 'span.katex-html'(el) { // We don't want to include the content of this element in the copied text. return ''; }, @@ -95,7 +95,7 @@ const gfmRules = { }, }, SanitizationFilter: { - 'a[name]:not([href]):empty'(el, text) { + 'a[name]:not([href]):empty'(el) { return el.outerHTML; }, 'dl'(el, text) { @@ -143,7 +143,7 @@ const gfmRules = { }, }, MarkdownFilter: { - 'br'(el, text) { + 'br'(el) { // Two spaces at the end of a line are turned into a BR return ' '; }, @@ -162,7 +162,7 @@ const gfmRules = { 'blockquote'(el, text) { return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); }, - 'img'(el, text) { + 'img'(el) { return `})`; }, 'a.anchor'(el, text) { @@ -222,10 +222,10 @@ const gfmRules = { 'sup'(el, text) { return `^${text}`; }, - 'hr'(el, text) { + 'hr'(el) { return '-----'; }, - 'table'(el, text) { + 'table'(el) { const theadEl = el.querySelector('thead'); const tbodyEl = el.querySelector('tbody'); if (!theadEl || !tbodyEl) return false; @@ -233,11 +233,11 @@ const gfmRules = { const theadText = CopyAsGFM.nodeToGFM(theadEl); const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); - return theadText + tbodyText; + return [theadText, tbodyText].join('\n'); }, 'thead'(el, text) { const cells = _.map(el.querySelectorAll('th'), (cell) => { - let chars = CopyAsGFM.nodeToGFM(cell).trim().length + 2; + let chars = CopyAsGFM.nodeToGFM(cell).length + 2; let before = ''; let after = ''; @@ -262,10 +262,15 @@ const gfmRules = { return before + middle + after; }); - return `${text}|${cells.join('|')}|`; + const separatorRow = `|${cells.join('|')}|`; + + return [text, separatorRow].join('\n'); }, - 'tr'(el, text) { - const cells = _.map(el.querySelectorAll('td, th'), cell => CopyAsGFM.nodeToGFM(cell).trim()); + 'tr'(el) { + const cellEls = el.querySelectorAll('td, th'); + if (cellEls.length === 0) return false; + + const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell)); return `| ${cells.join(' | ')} |`; }, }, @@ -273,12 +278,12 @@ const gfmRules = { class CopyAsGFM { constructor() { - $(document).on('copy', '.md, .wiki', (e) => { this.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); - $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { this.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); - $(document).on('paste', '.js-gfm-input', this.pasteGFM.bind(this)); + $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); + $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); + $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM); } - copyAsGFM(e, transformer) { + static copyAsGFM(e, transformer) { const clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; @@ -292,26 +297,59 @@ class CopyAsGFM { e.stopPropagation(); clipboardData.setData('text/plain', el.textContent); - clipboardData.setData('text/x-gfm', CopyAsGFM.nodeToGFM(el)); + clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); } - pasteGFM(e) { + static pasteGFM(e) { const clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; + const text = clipboardData.getData('text/plain'); const gfm = clipboardData.getData('text/x-gfm'); if (!gfm) return; e.preventDefault(); - window.gl.utils.insertText(e.target, gfm); + window.gl.utils.insertText(e.target, (textBefore, textAfter) => { + // If the text before the cursor contains an odd number of backticks, + // we are either inside an inline code span that starts with 1 backtick + // or a code block that starts with 3 backticks. + // This logic still holds when there are one or more _closed_ code spans + // or blocks that will have 2 or 6 backticks. + // This will break down when the actual code block contains an uneven + // number of backticks, but this is a rare edge case. + const backtickMatch = textBefore.match(/`/g); + const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1; + + if (insideCodeBlock) { + return text; + } + + return gfm; + }); } static transformGFMSelection(documentFragment) { - // If the documentFragment contains more than just Markdown, don't copy as GFM. - if (documentFragment.querySelector('.md, .wiki')) return null; + const gfmEls = documentFragment.querySelectorAll('.md, .wiki'); + switch (gfmEls.length) { + case 0: { + return documentFragment; + } + case 1: { + return gfmEls[0]; + } + default: { + const allGfmEl = document.createElement('div'); + + for (let i = 0; i < gfmEls.length; i += 1) { + const lineEl = gfmEls[i]; + allGfmEl.appendChild(lineEl); + allGfmEl.appendChild(document.createTextNode('\n\n')); + } - return documentFragment; + return allGfmEl; + } + } } static transformCodeSelection(documentFragment) { @@ -343,7 +381,7 @@ class CopyAsGFM { return codeEl; } - static nodeToGFM(node) { + static nodeToGFM(node, respectWhitespaceParam = false) { if (node.nodeType === Node.COMMENT_NODE) { return ''; } @@ -352,7 +390,9 @@ class CopyAsGFM { return node.textContent; } - const text = this.innerGFM(node); + const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE'); + + const text = this.innerGFM(node, respectWhitespace); if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { return text; @@ -366,7 +406,17 @@ class CopyAsGFM { if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue; - const result = func(node, text); + let result; + if (func.length === 2) { + // if `func` takes 2 arguments, it depends on text. + // if there is no text, we don't need to generate GFM for this node. + if (text.length === 0) continue; + + result = func(node, text); + } else { + result = func(node); + } + if (result === false) continue; return result; @@ -376,7 +426,7 @@ class CopyAsGFM { return text; } - static innerGFM(parentNode) { + static innerGFM(parentNode, respectWhitespace = false) { const nodes = parentNode.childNodes; const clonedParentNode = parentNode.cloneNode(true); @@ -386,13 +436,19 @@ class CopyAsGFM { const node = nodes[i]; const clonedNode = clonedNodes[i]; - const text = this.nodeToGFM(node); + const text = this.nodeToGFM(node, respectWhitespace); // `clonedNode.replaceWith(text)` is not yet widely supported clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); } - return clonedParentNode.innerText || clonedParentNode.textContent; + let nodeText = clonedParentNode.innerText || clonedParentNode.textContent; + + if (!respectWhitespace) { + nodeText = nodeText.trim(); + } + + return nodeText; } } diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js index 121d64db789..907b468e576 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/create_label.js @@ -1,5 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-param-reassign, wrap-iife, max-len */ -/* global Api */ +import Api from './api'; class CreateLabelDropdown { constructor ($el, namespacePath, projectPath) { diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js index e9a30476945..7c32a38fbe7 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -1,6 +1,7 @@ /* eslint-disable no-param-reassign */ import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; const global = window.gl || (window.gl = {}); global.cycleAnalytics = global.cycleAnalytics || {}; @@ -10,6 +11,9 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ items: Array, stage: Object, }, + components: { + userAvatarImage, + }, template: ` <div> <div class="events-description"> @@ -19,7 +23,8 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ <ul class="stage-event-list"> <li v-for="mergeRequest in items" class="stage-event-item"> <div class="item-details"> - <img class="avatar" :src="mergeRequest.author.avatarUrl"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/> <h5 class="item-title merge-merquest-title"> <a :href="mergeRequest.url"> {{ mergeRequest.title }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index 3626a9ce943..5f4a0ac8590 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ - import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; const global = window.gl || (window.gl = {}); global.cycleAnalytics = global.cycleAnalytics || {}; @@ -10,6 +10,9 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ items: Array, stage: Object, }, + components: { + userAvatarImage, + }, template: ` <div> <div class="events-description"> @@ -19,7 +22,8 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ <ul class="stage-event-list"> <li v-for="issue in items" class="stage-event-item"> <div class="item-details"> - <img class="avatar" :src="issue.author.avatarUrl"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="issue.author.avatarUrl"/> <h5 class="item-title issue-title"> <a class="issue-title" :href="issue.url"> {{ issue.title }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index c99bb8e9a13..11fee5410d9 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; import iconCommit from '../svg/icon_commit.svg'; const global = window.gl || (window.gl = {}); @@ -10,11 +11,12 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ items: Array, stage: Object, }, - + components: { + userAvatarImage, + }, data() { return { iconCommit }; }, - template: ` <div> <div class="events-description"> @@ -24,7 +26,8 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ <ul class="stage-event-list"> <li v-for="commit in items" class="stage-event-item"> <div class="item-details item-conmmit-component"> - <img class="avatar" :src="commit.author.avatarUrl"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="commit.author.avatarUrl"/> <h5 class="item-title commit-title"> <a :href="commit.commitUrl"> {{ commit.title }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js index 779458578ab..b7ba9360f70 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ - import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; const global = window.gl || (window.gl = {}); global.cycleAnalytics = global.cycleAnalytics || {}; @@ -10,6 +10,9 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ items: Array, stage: Object, }, + components: { + userAvatarImage, + }, template: ` <div> <div class="events-description"> @@ -19,7 +22,8 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ <ul class="stage-event-list"> <li v-for="issue in items" class="stage-event-item"> <div class="item-details"> - <img class="avatar" :src="issue.author.avatarUrl"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="issue.author.avatarUrl"/> <h5 class="item-title issue-title"> <a class="issue-title" :href="issue.url"> {{ issue.title }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js index 2b00593561f..f41a0d0e4ff 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ - import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; const global = window.gl || (window.gl = {}); global.cycleAnalytics = global.cycleAnalytics || {}; @@ -10,6 +10,9 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ items: Array, stage: Object, }, + components: { + userAvatarImage, + }, template: ` <div> <div class="events-description"> @@ -19,7 +22,8 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ <ul class="stage-event-list"> <li v-for="mergeRequest in items" class="stage-event-item"> <div class="item-details"> - <img class="avatar" :src="mergeRequest.author.avatarUrl"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/> <h5 class="item-title merge-merquest-title"> <a :href="mergeRequest.url"> {{ mergeRequest.title }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index acbde35eb55..d7c906c9d39 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; import iconBranch from '../svg/icon_branch.svg'; const global = window.gl || (window.gl = {}); @@ -13,6 +14,9 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ data() { return { iconBranch }; }, + components: { + userAvatarImage, + }, template: ` <div> <div class="events-description"> @@ -22,13 +26,14 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ <ul class="stage-event-list"> <li v-for="build in items" class="stage-event-item item-build-component"> <div class="item-details"> - <img class="avatar" :src="build.author.avatarUrl"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="build.author.avatarUrl"/> <h5 class="item-title"> <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> <i class="fa fa-code-fork"></i> - <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a> + <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a> <span class="icon-branch">${iconBranch}</span> - <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a> + <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a> </h5> <span> <a :href="build.url" class="build-date">{{ build.date }}</a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js index e306026429e..78cc97eea0b 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js @@ -29,9 +29,9 @@ global.cycleAnalytics.StageTestComponent = Vue.extend({ · <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> <i class="fa fa-code-fork"></i> - <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a> + <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a> <span class="icon-branch">${iconBranch}</span> - <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a> + <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a> </h5> <span> <a :href="build.url" class="issue-date"> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index c8e53cb554e..44791a93936 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -4,18 +4,16 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; import Translate from '../vue_shared/translate'; import LimitWarningComponent from './components/limit_warning_component'; - -require('./components/stage_code_component'); -require('./components/stage_issue_component'); -require('./components/stage_plan_component'); -require('./components/stage_production_component'); -require('./components/stage_review_component'); -require('./components/stage_staging_component'); -require('./components/stage_test_component'); -require('./components/total_time_component'); -require('./cycle_analytics_service'); -require('./cycle_analytics_store'); -require('./default_event_objects'); +import './components/stage_code_component'; +import './components/stage_issue_component'; +import './components/stage_plan_component'; +import './components/stage_production_component'; +import './components/stage_review_component'; +import './components/stage_staging_component'; +import './components/stage_test_component'; +import './components/total_time_component'; +import './cycle_analytics_service'; +import './cycle_analytics_store'; Vue.use(Translate); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 50bd394e90e..991f8c1f6fd 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -1,8 +1,8 @@ /* eslint-disable no-param-reassign */ -import { __ } from '../locale'; -require('../lib/utils/text_utility'); -const DEFAULT_EVENT_OBJECTS = require('./default_event_objects'); +import { __ } from '../locale'; +import '../lib/utils/text_utility'; +import DEFAULT_EVENT_OBJECTS from './default_event_objects'; const global = window.gl || (window.gl = {}); global.cycleAnalytics = global.cycleAnalytics || {}; diff --git a/app/assets/javascripts/cycle_analytics/default_event_objects.js b/app/assets/javascripts/cycle_analytics/default_event_objects.js index cfaf9835bf8..57f9019d2f8 100644 --- a/app/assets/javascripts/cycle_analytics/default_event_objects.js +++ b/app/assets/javascripts/cycle_analytics/default_event_objects.js @@ -1,4 +1,4 @@ -module.exports = { +export default { issue: { created_at: '', url: '', diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index 3ff3a9d977e..3f993213dd0 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -1,5 +1,6 @@ <script> import eventHub from '../eventhub'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { data() { @@ -22,6 +23,11 @@ default: 'btn-default', }, }, + + components: { + loadingIcon, + }, + methods: { doAction() { this.isLoading = true; @@ -44,11 +50,6 @@ :disabled="isLoading" @click="doAction"> {{ text }} - <i - v-if="isLoading" - class="fa fa-spinner fa-spin" - aria-hidden="true" - aria-label="Loading"> - </i> + <loading-icon v-if="isLoading" /> </button> </template> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 7315a9e11cb..a663e30dfd0 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -4,6 +4,7 @@ import DeployKeysService from '../service'; import DeployKeysStore from '../store'; import keysPanel from './keys_panel.vue'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { data() { @@ -28,6 +29,7 @@ }, components: { keysPanel, + loadingIcon, }, methods: { fetchKeys() { @@ -73,30 +75,32 @@ </script> <template> - <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys"> - <div - class="text-center" - v-if="isLoading && !hasKeys"> - <i - class="fa fa-spinner fa-spin fa-2x" - aria-hidden="true" - aria-label="Loading deploy keys"> - </i> - </div> + <div class="append-bottom-default deploy-keys"> + <loading-icon + v-if="isLoading && !hasKeys" + size="2" + label="Loading deploy keys" + /> <div v-else-if="hasKeys"> <keys-panel title="Enabled deploy keys for this project" :keys="keys.enabled_keys" - :store="store" /> + :store="store" + :endpoint="endpoint" + /> <keys-panel title="Deploy keys from projects you have access to" :keys="keys.available_project_keys" - :store="store" /> + :store="store" + :endpoint="endpoint" + /> <keys-panel v-if="keys.public_keys.length" title="Public deploy keys available to any project" :keys="keys.public_keys" - :store="store" /> + :store="store" + :endpoint="endpoint" + /> </div> </div> </template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 0a06a481b96..904f7f64fa8 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -11,6 +11,10 @@ type: Object, required: true, }, + endpoint: { + type: String, + required: true, + }, }, components: { actionBtn, @@ -19,6 +23,9 @@ timeagoDate() { return gl.utils.getTimeago().format(this.deployKey.created_at); }, + editDeployKeyPath() { + return `${this.endpoint}/${this.deployKey.id}/edit`; + }, }, methods: { isEnabled(id) { @@ -33,7 +40,8 @@ <div class="pull-left append-right-10 hidden-xs"> <i aria-hidden="true" - class="fa fa-key key-icon"> + class="fa fa-key key-icon" + > </i> </div> <div class="deploy-key-content key-list-item-info"> @@ -45,7 +53,8 @@ </div> <div v-if="deployKey.can_push" - class="write-access-allowed"> + class="write-access-allowed" + > Write access allowed </div> </div> @@ -53,7 +62,8 @@ <a v-for="project in deployKey.projects" class="label deploy-project-label" - :href="project.full_path"> + :href="project.full_path" + > {{ project.full_name }} </a> </div> @@ -61,20 +71,30 @@ <span class="key-created-at"> created {{ timeagoDate }} </span> + <a + v-if="deployKey.can_edit" + class="btn btn-small" + :href="editDeployKeyPath" + > + Edit + </a> <action-btn v-if="!isEnabled(deployKey.id)" :deploy-key="deployKey" - type="enable"/> + type="enable" + /> <action-btn v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" :deploy-key="deployKey" btn-css-class="btn-warning" - type="remove" /> + type="remove" + /> <action-btn v-else :deploy-key="deployKey" btn-css-class="btn-warning" - type="disable" /> + type="disable" + /> </div> </div> </template> diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index eccc470578b..9e6fb244af6 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -20,6 +20,10 @@ type: Object, required: true, }, + endpoint: { + type: String, + required: true, + }, }, components: { key, @@ -34,18 +38,22 @@ ({{ keys.length }}) </h5> <ul class="well-list" - v-if="keys.length"> + v-if="keys.length" + > <li v-for="deployKey in keys" :key="deployKey.id"> <key :deploy-key="deployKey" - :store="store" /> + :store="store" + :endpoint="endpoint" + /> </li> </ul> <div class="settings-message text-center" - v-else-if="showHelpBox"> + v-else-if="showHelpBox" + > No deploy keys found. Create one with the form above. </div> </div> diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 5aa3eb46a69..725ec7b9c70 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -1,6 +1,6 @@ /* eslint-disable class-methods-use-this */ -require('./lib/utils/url_utility'); +import './lib/utils/url_utility'; const UNFOLD_COUNT = 20; let isBound = false; diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js index 5f533b5761c..517bdb6be09 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import collapseIcon from '../icons/collapse_icon.svg'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; const DiffNoteAvatars = Vue.extend({ props: ['discussionId'], @@ -15,22 +16,24 @@ const DiffNoteAvatars = Vue.extend({ collapseIcon, }; }, + components: { + userAvatarImage, + }, template: ` <div class="diff-comment-avatar-holders" v-show="notesCount !== 0"> <div v-if="!isVisible"> - <img v-for="note in notesSubset" - class="avatar diff-comment-avatar has-tooltip js-diff-comment-avatar" - width="19" - height="19" - role="button" - data-container="body" - data-placement="top" - data-html="true" + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image + v-for="note in notesSubset" + class="diff-comment-avatar js-diff-comment-avatar" + @click.native="clickedAvatar($event)" + :img-src="note.authorAvatar" + :tooltip-text="getTooltipText(note)" :data-line-type="lineType" - :title="note.authorName + ': ' + note.noteTruncated" - :src="note.authorAvatar" - @click="clickedAvatar($event)" /> + :size="19" + data-html="true" + /> <span v-if="notesCount > shownAvatars" class="diff-comments-more-count has-tooltip js-diff-comment-avatar" data-container="body" @@ -150,6 +153,9 @@ const DiffNoteAvatars = Vue.extend({ setDiscussionVisible() { this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(':visible'); }, + getTooltipText(note) { + return `${note.authorName}: ${note.noteTruncated}`; + }, }, }); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index 8a0fd3bb4a7..37ddca29e71 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -16,6 +16,13 @@ const JumpToDiscussion = Vue.extend({ }; }, computed: { + buttonText: function () { + if (this.discussionId) { + return 'Jump to next unresolved discussion'; + } else { + return 'Jump to first unresolved discussion'; + } + }, allResolved: function () { return this.unresolvedDiscussionCount === 0; }, diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index fdd27534e0e..a2d33b0936e 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -2,19 +2,18 @@ /* global ResolveCount */ import Vue from 'vue'; - -require('./models/discussion'); -require('./models/note'); -require('./stores/comments'); -require('./services/resolve'); -require('./mixins/discussion'); -require('./components/comment_resolve_btn'); -require('./components/jump_to_discussion'); -require('./components/resolve_btn'); -require('./components/resolve_count'); -require('./components/resolve_discussion_btn'); -require('./components/diff_note_avatars'); -require('./components/new_issue_for_discussion'); +import './models/discussion'; +import './models/note'; +import './stores/comments'; +import './services/resolve'; +import './mixins/discussion'; +import './components/comment_resolve_btn'; +import './components/jump_to_discussion'; +import './components/resolve_btn'; +import './components/resolve_count'; +import './components/resolve_discussion_btn'; +import './components/diff_note_avatars'; +import './components/new_issue_for_discussion'; $(() => { const projectPath = document.querySelector('.merge-request').dataset.projectPath; diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index ba4f6d36fcb..807ab11d292 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -3,11 +3,7 @@ /* global CommentsStore */ import Vue from 'vue'; -import VueResource from 'vue-resource'; - -require('../../vue_shared/vue_resource_interceptor'); - -Vue.use(VueResource); +import '../../vue_shared/vue_resource_interceptor'; window.gl = window.gl || {}; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index a2b2ddfefc8..6fedf3f296b 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -3,7 +3,7 @@ /* global ActiveTabMemoizer */ /* global ShortcutsNavigation */ /* global Build */ -/* global Issuable */ +/* global IssuableIndex */ /* global ShortcutsIssuable */ /* global ZenMode */ /* global Milestone */ @@ -14,7 +14,6 @@ /* global NotificationsForm */ /* global TreeView */ /* global NotificationsDropdown */ -/* global UsersSelect */ /* global GroupAvatar */ /* global LineHighlighter */ /* global ProjectFork */ @@ -35,12 +34,13 @@ /* global ShortcutsWiki */ import Issue from './issue'; - import BindInOut from './behaviors/bind_in_out'; +import DeleteModal from './branches/branches_delete_modal'; import Group from './group'; import GroupName from './group_name'; import GroupsList from './groups_list'; import ProjectsList from './projects_list'; +import setupProjectEdit from './project_edit'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater'; import Landing from './landing'; @@ -51,9 +51,17 @@ import ShortcutsWiki from './shortcuts_wiki'; import Pipelines from './pipelines'; import BlobViewer from './blob/viewer/index'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; +<<<<<<< HEAD import RefSelectDropdown from './ref_select_dropdown'; const ShortcutsBlob = require('./shortcuts_blob'); +======= +import UsersSelect from './users_select'; +import RefSelectDropdown from './ref_select_dropdown'; +import GfmAutoComplete from './gfm_auto_complete'; +import ShortcutsBlob from './shortcuts_blob'; +import initSettingsPanels from './settings_panels'; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 (function() { var Dispatcher; @@ -78,6 +86,8 @@ const ShortcutsBlob = require('./shortcuts_blob'); path = page.split(':'); shortcut_handler = null; + new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(); + function initBlob() { new LineHighlighter(); @@ -113,20 +123,22 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:boards:show': case 'projects:boards:index': shortcut_handler = new ShortcutsNavigation(); + new UsersSelect(); break; - case 'projects:builds:show': + case 'projects:jobs:show': new Build(); break; case 'projects:merge_requests:index': case 'projects:issues:index': - if (gl.FilteredSearchManager) { - new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); + if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) { + const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); + filteredSearchManager.setup(); } - Issuable.init(); - new gl.IssuableBulkActions({ - prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', - }); + const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_'; + IssuableIndex.init(pagePrefix); + shortcut_handler = new ShortcutsNavigation(); + new UsersSelect(); break; case 'projects:issues:show': new Issue(); @@ -139,6 +151,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); new Milestone(); new Sidebar(); break; + case 'groups:issues': + case 'groups:merge_requests': + new UsersSelect(); + break; case 'dashboard:todos:index': new gl.Todos(); break; @@ -180,6 +196,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); break; case 'projects:branches:index': gl.AjaxLoadingSpinner.init(); + new DeleteModal(); break; case 'projects:issues:new': case 'projects:issues:edit': @@ -206,6 +223,19 @@ const ShortcutsBlob = require('./shortcuts_blob'); new ZenMode(); new gl.GLForm($('.tag-form')); new RefSelectDropdown($('.js-branch-select'), window.gl.availableRefs); +<<<<<<< HEAD +======= + break; + case 'projects:snippets:new': + case 'projects:snippets:edit': + case 'projects:snippets:create': + case 'projects:snippets:update': + case 'snippets:new': + case 'snippets:edit': + case 'snippets:create': + case 'snippets:update': + new gl.GLForm($('.snippet-form')); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 break; case 'projects:releases:edit': new ZenMode(); @@ -223,6 +253,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'dashboard:activity': new gl.Activities(); break; + case 'dashboard:issues': + case 'dashboard:merge_requests': + new UsersSelect(); + break; case 'projects:commit:show': new Commit(); new gl.Diff(); @@ -247,6 +281,12 @@ const ShortcutsBlob = require('./shortcuts_blob'); if ($('#tree-slider').length) { new TreeView(); } + if ($('.blob-viewer').length) { + new BlobViewer(); + } + break; + case 'projects:edit': + setupProjectEdit(); break; case 'projects:pipelines:builds': case 'projects:pipelines:failures': @@ -300,6 +340,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:tree:show': shortcut_handler = new ShortcutsNavigation(); new TreeView(); + new BlobViewer(); gl.TargetBranchDropDown.bootstrap(); break; case 'projects:find_file:show': @@ -360,6 +401,8 @@ const ShortcutsBlob = require('./shortcuts_blob'); // Initialize Protected Tag Settings new ProtectedTagCreate(); new ProtectedTagEditList(); + // Initialize expandable settings panels + initSettingsPanels(); break; case 'projects:ci_cd:show': new gl.ProjectVariables(); @@ -371,10 +414,16 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'users:show': new UserCallout(); break; + case 'admin:conversational_development_index:show': + new UserCallout(); + break; case 'snippets:show': new LineHighlighter(); new BlobViewer(); break; + case 'import:fogbugz:new_user_map': + new UsersSelect(); + break; } switch (path.first()) { case 'sessions': diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index de3927d683c..70cd337fb8a 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -1,44 +1,42 @@ -/* eslint-disable */ - import utils from './utils'; import { SELECTED_CLASS, IGNORE_CLASS } from './constants'; -var DropDown = function(list) { - this.currentIndex = 0; - this.hidden = true; - this.list = typeof list === 'string' ? document.querySelector(list) : list; - this.items = []; +class DropDown { + constructor(list) { + this.currentIndex = 0; + this.hidden = true; + this.list = typeof list === 'string' ? document.querySelector(list) : list; + this.items = []; - this.eventWrapper = {}; + this.eventWrapper = {}; - this.getItems(); - this.initTemplateString(); - this.addEvents(); + this.getItems(); + this.initTemplateString(); + this.addEvents(); - this.initialState = list.innerHTML; -}; + this.initialState = list.innerHTML; + } -Object.assign(DropDown.prototype, { - getItems: function() { + getItems() { this.items = [].slice.call(this.list.querySelectorAll('li')); return this.items; - }, + } - initTemplateString: function() { - var items = this.items || this.getItems(); + initTemplateString() { + const items = this.items || this.getItems(); - var templateString = ''; + let templateString = ''; if (items.length > 0) templateString = items[items.length - 1].outerHTML; this.templateString = templateString; return this.templateString; - }, + } - clickEvent: function(e) { + clickEvent(e) { if (e.target.tagName === 'UL') return; if (e.target.classList.contains(IGNORE_CLASS)) return; - var selected = utils.closest(e.target, 'LI'); + const selected = utils.closest(e.target, 'LI'); if (!selected) return; this.addSelectedClass(selected); @@ -46,95 +44,95 @@ Object.assign(DropDown.prototype, { e.preventDefault(); this.hide(); - var listEvent = new CustomEvent('click.dl', { + const listEvent = new CustomEvent('click.dl', { detail: { list: this, - selected: selected, + selected, data: e.target.dataset, }, }); this.list.dispatchEvent(listEvent); - }, + } - addSelectedClass: function (selected) { + addSelectedClass(selected) { this.removeSelectedClasses(); selected.classList.add(SELECTED_CLASS); - }, + } - removeSelectedClasses: function () { + removeSelectedClasses() { const items = this.items || this.getItems(); items.forEach(item => item.classList.remove(SELECTED_CLASS)); - }, + } - addEvents: function() { - this.eventWrapper.clickEvent = this.clickEvent.bind(this) + addEvents() { + this.eventWrapper.clickEvent = this.clickEvent.bind(this); this.list.addEventListener('click', this.eventWrapper.clickEvent); - }, - - toggle: function() { - this.hidden ? this.show() : this.hide(); - }, + } - setData: function(data) { + setData(data) { this.data = data; this.render(data); - }, + } - addData: function(data) { + addData(data) { this.data = (this.data || []).concat(data); this.render(this.data); - }, + } - render: function(data) { + render(data) { const children = data ? data.map(this.renderChildren.bind(this)) : []; const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; renderableList.innerHTML = children.join(''); - }, + } - renderChildren: function(data) { - var html = utils.template(this.templateString, data); - var template = document.createElement('div'); + renderChildren(data) { + const html = utils.template(this.templateString, data); + const template = document.createElement('div'); template.innerHTML = html; - this.setImagesSrc(template); + DropDown.setImagesSrc(template); template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block'; return template.firstChild.outerHTML; - }, - - setImagesSrc: function(template) { - const images = [].slice.call(template.querySelectorAll('img[data-src]')); - - images.forEach((image) => { - image.src = image.getAttribute('data-src'); - image.removeAttribute('data-src'); - }); - }, + } - show: function() { + show() { if (!this.hidden) return; this.list.style.display = 'block'; this.currentIndex = 0; this.hidden = false; - }, + } - hide: function() { + hide() { if (this.hidden) return; this.list.style.display = 'none'; this.currentIndex = 0; this.hidden = true; - }, + } - toggle: function () { - this.hidden ? this.show() : this.hide(); - }, + toggle() { + if (this.hidden) return this.show(); - destroy: function() { + return this.hide(); + } + + destroy() { this.hide(); this.list.removeEventListener('click', this.eventWrapper.clickEvent); } -}); + + static setImagesSrc(template) { + const images = [...template.querySelectorAll('img[data-src]')]; + + images.forEach((image) => { + const img = image; + + img.src = img.getAttribute('data-src'); + img.removeAttribute('data-src'); + }); + } +} export default DropDown; diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js index 6eb9f314af7..2a02ede72bf 100644 --- a/app/assets/javascripts/droplab/drop_lab.js +++ b/app/assets/javascripts/droplab/drop_lab.js @@ -1,99 +1,99 @@ -/* eslint-disable */ - import HookButton from './hook_button'; import HookInput from './hook_input'; import utils from './utils'; import Keyboard from './keyboard'; import { DATA_TRIGGER } from './constants'; -var DropLab = function() { - this.ready = false; - this.hooks = []; - this.queuedData = []; - this.config = {}; +class DropLab { + constructor() { + this.ready = false; + this.hooks = []; + this.queuedData = []; + this.config = {}; - this.eventWrapper = {}; -}; + this.eventWrapper = {}; + } -Object.assign(DropLab.prototype, { - loadStatic: function(){ - var dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`)); + loadStatic() { + const dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`)); this.addHooks(dropdownTriggers); - }, + } - addData: function () { - var args = [].slice.apply(arguments); - this.applyArgs(args, '_addData'); - }, + addData(...args) { + this.applyArgs(args, 'processAddData'); + } - setData: function() { - var args = [].slice.apply(arguments); - this.applyArgs(args, '_setData'); - }, + setData(...args) { + this.applyArgs(args, 'processSetData'); + } - destroy: function() { + destroy() { this.hooks.forEach(hook => hook.destroy()); this.hooks = []; this.removeEvents(); - }, + } - applyArgs: function(args, methodName) { - if (this.ready) return this[methodName].apply(this, args); + applyArgs(args, methodName) { + if (this.ready) return this[methodName](...args); this.queuedData = this.queuedData || []; this.queuedData.push(args); - }, - _addData: function(trigger, data) { - this._processData(trigger, data, 'addData'); - }, + return this.ready; + } + + processAddData(trigger, data) { + this.processData(trigger, data, 'addData'); + } - _setData: function(trigger, data) { - this._processData(trigger, data, 'setData'); - }, + processSetData(trigger, data) { + this.processData(trigger, data, 'setData'); + } - _processData: function(trigger, data, methodName) { + processData(trigger, data, methodName) { this.hooks.forEach((hook) => { if (Array.isArray(trigger)) hook.list[methodName](trigger); if (hook.trigger.id === trigger) hook.list[methodName](data); }); - }, + } - addEvents: function() { - this.eventWrapper.documentClicked = this.documentClicked.bind(this) + addEvents() { + this.eventWrapper.documentClicked = this.documentClicked.bind(this); document.addEventListener('click', this.eventWrapper.documentClicked); - }, + } - documentClicked: function(e) { + documentClicked(e) { let thisTag = e.target; if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL'); - if (utils.isDropDownParts(thisTag, this.hooks) || utils.isDropDownParts(e.target, this.hooks)) return; + if (utils.isDropDownParts(thisTag, this.hooks)) return; + if (utils.isDropDownParts(e.target, this.hooks)) return; this.hooks.forEach(hook => hook.list.hide()); - }, + } - removeEvents: function(){ + removeEvents() { document.removeEventListener('click', this.eventWrapper.documentClicked); - }, - - changeHookList: function(trigger, list, plugins, config) { - const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger; + } + changeHookList(trigger, list, plugins, config) { + const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger; this.hooks.forEach((hook, i) => { - hook.list.list.dataset.dropdownActive = false; + const aHook = hook; + + aHook.list.list.dataset.dropdownActive = false; - if (hook.trigger !== availableTrigger) return; + if (aHook.trigger !== availableTrigger) return; - hook.destroy(); + aHook.destroy(); this.hooks.splice(i, 1); this.addHook(availableTrigger, list, plugins, config); }); - }, + } - addHook: function(hook, list, plugins, config) { + addHook(hook, list, plugins, config) { const availableHook = typeof hook === 'string' ? document.querySelector(hook) : hook; let availableList; @@ -111,18 +111,18 @@ Object.assign(DropLab.prototype, { this.hooks.push(new HookObject(availableHook, availableList, plugins, config)); return this; - }, + } - addHooks: function(hooks, plugins, config) { + addHooks(hooks, plugins, config) { hooks.forEach(hook => this.addHook(hook, null, plugins, config)); return this; - }, + } - setConfig: function(obj){ + setConfig(obj) { this.config = obj; - }, + } - fireReady: function() { + fireReady() { const readyEvent = new CustomEvent('ready.dl', { detail: { dropdown: this, @@ -131,10 +131,14 @@ Object.assign(DropLab.prototype, { document.dispatchEvent(readyEvent); this.ready = true; - }, + } - init: function (hook, list, plugins, config) { - hook ? this.addHook(hook, list, plugins, config) : this.loadStatic(); + init(hook, list, plugins, config) { + if (hook) { + this.addHook(hook, list, plugins, config); + } else { + this.loadStatic(); + } this.addEvents(); @@ -146,7 +150,7 @@ Object.assign(DropLab.prototype, { this.queuedData = []; return this; - }, -}); + } +} export default DropLab; diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js index 2f840083571..cf78165b0d8 100644 --- a/app/assets/javascripts/droplab/hook.js +++ b/app/assets/javascripts/droplab/hook.js @@ -1,22 +1,15 @@ -/* eslint-disable */ - import DropDown from './drop_down'; -var Hook = function(trigger, list, plugins, config){ - this.trigger = trigger; - this.list = new DropDown(list); - this.type = 'Hook'; - this.event = 'click'; - this.plugins = plugins || []; - this.config = config || {}; - this.id = trigger.id; -}; - -Object.assign(Hook.prototype, { - - addEvents: function(){}, - - constructor: Hook, -}); +class Hook { + constructor(trigger, list, plugins, config) { + this.trigger = trigger; + this.list = new DropDown(list); + this.type = 'Hook'; + this.event = 'click'; + this.plugins = plugins || []; + this.config = config || {}; + this.id = trigger.id; + } +} export default Hook; diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js index be8aead1303..af45eba74e7 100644 --- a/app/assets/javascripts/droplab/hook_button.js +++ b/app/assets/javascripts/droplab/hook_button.js @@ -1,65 +1,58 @@ -/* eslint-disable */ - import Hook from './hook'; -var HookButton = function(trigger, list, plugins, config) { - Hook.call(this, trigger, list, plugins, config); - - this.type = 'button'; - this.event = 'click'; +class HookButton extends Hook { + constructor(trigger, list, plugins, config) { + super(trigger, list, plugins, config); - this.eventWrapper = {}; + this.type = 'button'; + this.event = 'click'; - this.addEvents(); - this.addPlugins(); -}; + this.eventWrapper = {}; -HookButton.prototype = Object.create(Hook.prototype); + this.addEvents(); + this.addPlugins(); + } -Object.assign(HookButton.prototype, { - addPlugins: function() { + addPlugins() { this.plugins.forEach(plugin => plugin.init(this)); - }, + } - clicked: function(e){ - var buttonEvent = new CustomEvent('click.dl', { + clicked(e) { + const buttonEvent = new CustomEvent('click.dl', { detail: { hook: this, }, bubbles: true, - cancelable: true + cancelable: true, }); e.target.dispatchEvent(buttonEvent); this.list.toggle(); - }, + } - addEvents: function(){ + addEvents() { this.eventWrapper.clicked = this.clicked.bind(this); this.trigger.addEventListener('click', this.eventWrapper.clicked); - }, + } - removeEvents: function(){ + removeEvents() { this.trigger.removeEventListener('click', this.eventWrapper.clicked); - }, + } - restoreInitialState: function() { + restoreInitialState() { this.list.list.innerHTML = this.list.initialState; - }, + } - removePlugins: function() { + removePlugins() { this.plugins.forEach(plugin => plugin.destroy()); - }, + } - destroy: function() { + destroy() { this.restoreInitialState(); this.removeEvents(); this.removePlugins(); - }, - - constructor: HookButton, -}); - + } +} export default HookButton; diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/droplab/hook_input.js index 05082334045..19131a64f2c 100644 --- a/app/assets/javascripts/droplab/hook_input.js +++ b/app/assets/javascripts/droplab/hook_input.js @@ -1,25 +1,23 @@ -/* eslint-disable */ - import Hook from './hook'; -var HookInput = function(trigger, list, plugins, config) { - Hook.call(this, trigger, list, plugins, config); +class HookInput extends Hook { + constructor(trigger, list, plugins, config) { + super(trigger, list, plugins, config); - this.type = 'input'; - this.event = 'input'; + this.type = 'input'; + this.event = 'input'; - this.eventWrapper = {}; + this.eventWrapper = {}; - this.addEvents(); - this.addPlugins(); -}; + this.addEvents(); + this.addPlugins(); + } -Object.assign(HookInput.prototype, { - addPlugins: function() { + addPlugins() { this.plugins.forEach(plugin => plugin.init(this)); - }, + } - addEvents: function(){ + addEvents() { this.eventWrapper.mousedown = this.mousedown.bind(this); this.eventWrapper.input = this.input.bind(this); this.eventWrapper.keyup = this.keyup.bind(this); @@ -29,19 +27,19 @@ Object.assign(HookInput.prototype, { this.trigger.addEventListener('input', this.eventWrapper.input); this.trigger.addEventListener('keyup', this.eventWrapper.keyup); this.trigger.addEventListener('keydown', this.eventWrapper.keydown); - }, + } - removeEvents: function() { + removeEvents() { this.hasRemovedEvents = true; this.trigger.removeEventListener('mousedown', this.eventWrapper.mousedown); this.trigger.removeEventListener('input', this.eventWrapper.input); this.trigger.removeEventListener('keyup', this.eventWrapper.keyup); this.trigger.removeEventListener('keydown', this.eventWrapper.keydown); - }, + } - input: function(e) { - if(this.hasRemovedEvents) return; + input(e) { + if (this.hasRemovedEvents) return; this.list.show(); @@ -51,12 +49,12 @@ Object.assign(HookInput.prototype, { text: e.target.value, }, bubbles: true, - cancelable: true + cancelable: true, }); e.target.dispatchEvent(inputEvent); - }, + } - mousedown: function(e) { + mousedown(e) { if (this.hasRemovedEvents) return; const mouseEvent = new CustomEvent('mousedown.dl', { @@ -68,21 +66,21 @@ Object.assign(HookInput.prototype, { cancelable: true, }); e.target.dispatchEvent(mouseEvent); - }, + } - keyup: function(e) { + keyup(e) { if (this.hasRemovedEvents) return; this.keyEvent(e, 'keyup.dl'); - }, + } - keydown: function(e) { + keydown(e) { if (this.hasRemovedEvents) return; this.keyEvent(e, 'keydown.dl'); - }, + } - keyEvent: function(e, eventName) { + keyEvent(e, eventName) { this.list.show(); const keyEvent = new CustomEvent(eventName, { @@ -96,17 +94,17 @@ Object.assign(HookInput.prototype, { cancelable: true, }); e.target.dispatchEvent(keyEvent); - }, + } - restoreInitialState: function() { + restoreInitialState() { this.list.list.innerHTML = this.list.initialState; - }, + } - removePlugins: function() { + removePlugins() { this.plugins.forEach(plugin => plugin.destroy()); - }, + } - destroy: function() { + destroy() { this.restoreInitialState(); this.removeEvents(); @@ -114,6 +112,6 @@ Object.assign(HookInput.prototype, { this.list.destroy(); } -}); +} export default HookInput; diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/droplab/keyboard.js index 36740a430e1..02f1b805ce4 100644 --- a/app/assets/javascripts/droplab/keyboard.js +++ b/app/assets/javascripts/droplab/keyboard.js @@ -8,7 +8,7 @@ const Keyboard = function () { var isUpArrow = false; var isDownArrow = false; var removeHighlight = function removeHighlight(list) { - var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0); + var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider):not(.hidden)'), 0); var listItems = []; for(var i = 0; i < itemElements.length; i++) { var listItem = itemElements[i]; diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/droplab/plugins/ajax.js index 12afe53ed76..c0da5866139 100644 --- a/app/assets/javascripts/droplab/plugins/ajax.js +++ b/app/assets/javascripts/droplab/plugins/ajax.js @@ -1,25 +1,8 @@ /* eslint-disable */ +import AjaxCache from '~/lib/utils/ajax_cache'; + const Ajax = { - _loadUrlData: function _loadUrlData(url) { - var self = this; - return new Promise(function(resolve, reject) { - var xhr = new XMLHttpRequest; - xhr.open('GET', url, true); - xhr.onreadystatechange = function () { - if(xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status === 200) { - var data = JSON.parse(xhr.responseText); - self.cache[url] = data; - return resolve(data); - } else { - return reject([xhr.responseText, xhr.status]); - } - } - }; - xhr.send(); - }); - }, _loadData: function _loadData(data, config, self) { if (config.loadingTemplate) { var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]'); @@ -31,7 +14,6 @@ const Ajax = { init: function init(hook) { var self = this; self.destroyed = false; - self.cache = self.cache || {}; var config = hook.config.Ajax; this.hook = hook; if (!config || !config.endpoint || !config.method) { @@ -48,14 +30,10 @@ const Ajax = { this.listTemplate = dynamicList.outerHTML; dynamicList.outerHTML = loadingTemplate.outerHTML; } - if (self.cache[config.endpoint]) { - self._loadData(self.cache[config.endpoint], config, self); - } else { - this._loadUrlData(config.endpoint) - .then(function(d) { - self._loadData(d, config, self); - }, config.onError).catch(config.onError); - } + + AjaxCache.retrieve(config.endpoint) + .then((data) => self._loadData(data, config, self)) + .catch(config.onError); }, destroy: function() { this.destroyed = true; diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js index cfd7e2ca189..1db20227a16 100644 --- a/app/assets/javascripts/droplab/plugins/ajax_filter.js +++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js @@ -1,4 +1,5 @@ /* eslint-disable */ +import AjaxCache from '../../lib/utils/ajax_cache'; const AjaxFilter = { init: function(hook) { @@ -58,50 +59,27 @@ const AjaxFilter = { this.loading = true; var params = config.params || {}; params[config.searchKey] = searchValue; - var self = this; - self.cache = self.cache || {}; var url = config.endpoint + this.buildParams(params); - var urlCachedData = self.cache[url]; - if (urlCachedData) { - self._loadData(urlCachedData, config, self); - } else { - this._loadUrlData(url) - .then(function(data) { - self._loadData(data, config, self); - }, config.onError).catch(config.onError); - } - }, - - _loadUrlData: function _loadUrlData(url) { - var self = this; - return new Promise(function(resolve, reject) { - var xhr = new XMLHttpRequest; - xhr.open('GET', url, true); - xhr.onreadystatechange = function () { - if(xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status === 200) { - var data = JSON.parse(xhr.responseText); - self.cache[url] = data; - return resolve(data); - } else { - return reject([xhr.responseText, xhr.status]); - } + return AjaxCache.retrieve(url) + .then((data) => { + this._loadData(data, config); + if (config.onLoadingFinished) { + config.onLoadingFinished(data); } - }; - xhr.send(); - }); + }) + .catch(config.onError); }, - _loadData: function _loadData(data, config, self) { - const list = self.hook.list; + _loadData(data, config) { + const list = this.hook.list; if (config.loadingTemplate && list.data === undefined || list.data.length === 0) { const dataLoadingTemplate = list.list.querySelector('[data-loading-template]'); if (dataLoadingTemplate) { - dataLoadingTemplate.outerHTML = self.listTemplate; + dataLoadingTemplate.outerHTML = this.listTemplate; } } - if (!self.destroyed) { + if (!this.destroyed) { var hookListChildren = list.list.children; var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic'); if (onlyDynamicList && data.length === 0) { @@ -109,7 +87,7 @@ const AjaxFilter = { } list.setData.call(list, data); } - self.notLoading(); + this.notLoading(); list.currentIndex = 0; }, diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index b3a76fbb43e..98ddcc20036 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -1,108 +1,159 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, one-var, no-var, one-var-declaration-per-line, no-unused-vars, camelcase, quotes, no-useless-concat, prefer-template, quote-props, comma-dangle, object-shorthand, consistent-return, prefer-arrow-callback */ /* global Dropzone */ -require('./preview_markdown'); +import './preview_markdown'; window.DropzoneInput = (function() { function DropzoneInput(form) { - var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, uploads_path, showError, showSpinner, uploadFile, uploadProgress; + var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile, addFileToForm; Dropzone.autoDiscover = false; - alertClass = "alert alert-danger alert-dismissable div-dropzone-alert"; - alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\""; - divHover = "<div class=\"div-dropzone-hover\"></div>"; - divSpinner = "<div class=\"div-dropzone-spinner\"></div>"; - divAlert = "<div class=\"" + alertClass + "\"></div>"; - iconPaperclip = "<i class=\"fa fa-paperclip div-dropzone-icon\"></i>"; - iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>"; - uploadProgress = $("<div class=\"div-dropzone-progress\"></div>"); - btnAlert = "<button type=\"button\"" + alertAttr + ">×</button>"; - uploads_path = window.uploads_path || null; - max_file_size = gon.max_file_size || 10; - form_textarea = $(form).find(".js-gfm-input"); - form_textarea.wrap("<div class=\"div-dropzone\"></div>"); - form_textarea.on('paste', (function(_this) { + divHover = '<div class="div-dropzone-hover"></div>'; + iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>'; + $attachButton = form.find('.button-attach-file'); + $attachingFileMessage = form.find('.attaching-file-message'); + $cancelButton = form.find('.button-cancel-uploading-files'); + $retryLink = form.find('.retry-uploading-link'); + $uploadProgress = form.find('.uploading-progress'); + $uploadingErrorContainer = form.find('.uploading-error-container'); + $uploadingErrorMessage = form.find('.uploading-error-message'); + $uploadingProgressContainer = form.find('.uploading-progress-container'); + uploadsPath = window.uploads_path || null; + maxFileSize = gon.max_file_size || 10; + formTextarea = form.find('.js-gfm-input'); + formTextarea.wrap('<div class="div-dropzone"></div>'); + formTextarea.on('paste', (function(_this) { return function(event) { return handlePaste(event); }; })(this)); - $mdArea = $(form_textarea).closest('.md-area'); - $(form).setupMarkdownPreview(); - form_dropzone = $(form).find('.div-dropzone'); - form_dropzone.parent().addClass("div-dropzone-wrapper"); - form_dropzone.append(divHover); - form_dropzone.find(".div-dropzone-hover").append(iconPaperclip); - form_dropzone.append(divSpinner); - form_dropzone.find(".div-dropzone-spinner").append(iconSpinner); - form_dropzone.find(".div-dropzone-spinner").append(uploadProgress); - form_dropzone.find(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); - if (!uploads_path) return; + // Add dropzone area to the form. + $mdArea = formTextarea.closest('.md-area'); + form.setupMarkdownPreview(); + $formDropzone = form.find('.div-dropzone'); + $formDropzone.parent().addClass('div-dropzone-wrapper'); + $formDropzone.append(divHover); + $formDropzone.find('.div-dropzone-hover').append(iconPaperclip); + + if (!uploadsPath) return; - dropzone = form_dropzone.dropzone({ - url: uploads_path, - dictDefaultMessage: "", + dropzone = $formDropzone.dropzone({ + url: uploadsPath, + dictDefaultMessage: '', clickable: true, - paramName: "file", - maxFilesize: max_file_size, + paramName: 'file', + maxFilesize: maxFileSize, uploadMultiple: false, headers: { - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') }, previewContainer: false, processing: function() { - return $(".div-dropzone-alert").alert("close"); + return $('.div-dropzone-alert').alert('close'); }, dragover: function() { $mdArea.addClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0.7); + form.find('.div-dropzone-hover').css('opacity', 0.7); }, dragleave: function() { $mdArea.removeClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0); + form.find('.div-dropzone-hover').css('opacity', 0); }, drop: function() { $mdArea.removeClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0); - form_textarea.focus(); + form.find('.div-dropzone-hover').css('opacity', 0); + formTextarea.focus(); }, success: function(header, response) { const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length; const shouldPad = processingFileCount >= 1; pasteText(response.link.markdown, shouldPad); + // Show 'Attach a file' link only when all files have been uploaded. + if (!processingFileCount) $attachButton.removeClass('hide'); + addFileToForm(response.link.url); }, - error: function(temp) { - var checkIfMsgExists, errorAlert; - errorAlert = $(form).find('.error-alert'); - checkIfMsgExists = errorAlert.children().length; - if (checkIfMsgExists === 0) { - errorAlert.append(divAlert); - $(".div-dropzone-alert").append(btnAlert + "Attaching the file failed."); - } + error: function(file, errorMessage = 'Attaching the file failed.', xhr) { + // If 'error' event is fired by dropzone, the second parameter is error message. + // If the 'errorMessage' parameter is empty, the default error message is set. + // If the 'error' event is fired by backend (xhr) error response, the third parameter is + // xhr object (xhr.responseText is error message). + // On error we hide the 'Attach' and 'Cancel' buttons + // and show an error. + + // If there's xhr error message, let's show it instead of dropzone's one. + const message = xhr ? xhr.responseText : errorMessage; + + $uploadingErrorContainer.removeClass('hide'); + $uploadingErrorMessage.html(message); + $attachButton.addClass('hide'); + $cancelButton.addClass('hide'); }, totaluploadprogress: function(totalUploadProgress) { - uploadProgress.text(Math.round(totalUploadProgress) + "%"); + updateAttachingMessage(this.files, $attachingFileMessage); + $uploadProgress.text(Math.round(totalUploadProgress) + '%'); + }, + sending: function(file) { + // DOM elements already exist. + // Instead of dynamically generating them, + // we just either hide or show them. + $attachButton.addClass('hide'); + $uploadingErrorContainer.addClass('hide'); + $uploadingProgressContainer.removeClass('hide'); + $cancelButton.removeClass('hide'); }, - sending: function() { - form_dropzone.find(".div-dropzone-spinner").css({ - "opacity": 0.7, - "display": "inherit" - }); + removedfile: function() { + $attachButton.removeClass('hide'); + $cancelButton.addClass('hide'); + $uploadingProgressContainer.addClass('hide'); + $uploadingErrorContainer.addClass('hide'); }, queuecomplete: function() { - uploadProgress.text(""); - $(".dz-preview").remove(); - $(".markdown-area").trigger("input"); - $(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); + $('.dz-preview').remove(); + $('.markdown-area').trigger('input'); + + $uploadingProgressContainer.addClass('hide'); + $cancelButton.addClass('hide'); } }); - child = $(dropzone[0]).children("textarea"); + + child = $(dropzone[0]).children('textarea'); + + // removeAllFiles(true) stops uploading files (if any) + // and remove them from dropzone files queue. + $cancelButton.on('click', (e) => { + const target = e.target.closest('form').querySelector('.div-dropzone'); + + e.preventDefault(); + e.stopPropagation(); + Dropzone.forElement(target).removeAllFiles(true); + }); + + // If 'error' event is fired, we store a failed files, + // clear dropzone files queue, change status of failed files to undefined, + // and add that files to the dropzone files queue again. + // addFile() adds file to dropzone files queue and upload it. + $retryLink.on('click', (e) => { + const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone')); + const failedFiles = dropzoneInstance.files; + + e.preventDefault(); + + // 'true' parameter of removeAllFiles() cancels uploading of files that are being uploaded at the moment. + dropzoneInstance.removeAllFiles(true); + + failedFiles.map((failedFile, i) => { + const file = failedFile; + + if (file.status === Dropzone.ERROR) { + file.status = undefined; + file.accepted = undefined; + } + + return dropzoneInstance.addFile(file); + }); + }); + handlePaste = function(event) { var filename, image, pasteEvent, text; pasteEvent = event.originalEvent; @@ -110,25 +161,27 @@ window.DropzoneInput = (function() { image = isImage(pasteEvent); if (image) { event.preventDefault(); - filename = getFilename(pasteEvent) || "image.png"; - text = "{{" + filename + "}}"; + filename = getFilename(pasteEvent) || 'image.png'; + text = `{{${filename}}}`; pasteText(text); return uploadFile(image.getAsFile(), filename); } } }; + isImage = function(data) { var i, item; i = 0; while (i < data.clipboardData.items.length) { item = data.clipboardData.items[i]; - if (item.type.indexOf("image") !== -1) { + if (item.type.indexOf('image') !== -1) { return item; } i += 1; } return false; }; + pasteText = function(text, shouldPad) { var afterSelection, beforeSelection, caretEnd, caretStart, textEnd; var formattedText = text; @@ -142,31 +195,38 @@ window.DropzoneInput = (function() { $(child).val(beforeSelection + formattedText + afterSelection); textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length); textarea.style.height = `${textarea.scrollHeight}px`; - return form_textarea.trigger("input"); + formTextarea.get(0).dispatchEvent(new Event('input')); + return formTextarea.trigger('input'); }; + + addFileToForm = function(path) { + $(form).append('<input type="hidden" name="files[]" value="' + _.escape(path) + '">'); + }; + getFilename = function(e) { var value; if (window.clipboardData && window.clipboardData.getData) { - value = window.clipboardData.getData("Text"); + value = window.clipboardData.getData('Text'); } else if (e.clipboardData && e.clipboardData.getData) { - value = e.clipboardData.getData("text/plain"); + value = e.clipboardData.getData('text/plain'); } value = value.split("\r"); return value.first(); }; + uploadFile = function(item, filename) { var formData; formData = new FormData(); - formData.append("file", item, filename); + formData.append('file', item, filename); return $.ajax({ - url: uploads_path, - type: "POST", + url: uploadsPath, + type: 'POST', data: formData, - dataType: "json", + dataType: 'json', processData: false, contentType: false, headers: { - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') }, beforeSend: function() { showSpinner(); @@ -183,44 +243,54 @@ window.DropzoneInput = (function() { } }); }; + + updateAttachingMessage = (files, messageContainer) => { + let attachingMessage; + const filesCount = files.filter(function(file) { + return file.status === 'uploading' || + file.status === 'queued'; + }).length; + + // Dinamycally change uploading files text depending on files number in + // dropzone files queue. + if (filesCount > 1) { + attachingMessage = 'Attaching ' + filesCount + ' files -'; + } else { + attachingMessage = 'Attaching a file -'; + } + + messageContainer.text(attachingMessage); + }; + insertToTextArea = function(filename, url) { return $(child).val(function(index, val) { - return val.replace("{{" + filename + "}}", url); + return val.replace(`{{${filename}}}`, url); }); }; + appendToTextArea = function(url) { return $(child).val(function(index, val) { return val + url + "\n"; }); }; + showSpinner = function(e) { - return form.find(".div-dropzone-spinner").css({ - "opacity": 0.7, - "display": "inherit" - }); + return $uploadingProgressContainer.removeClass('hide'); }; + closeSpinner = function() { - return form.find(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); + return $uploadingProgressContainer.addClass('hide'); }; + showError = function(message) { - var checkIfMsgExists, errorAlert; - errorAlert = $(form).find('.error-alert'); - checkIfMsgExists = errorAlert.children().length; - if (checkIfMsgExists === 0) { - errorAlert.append(divAlert); - return $(".div-dropzone-alert").append(btnAlert + message); - } + $uploadingErrorContainer.removeClass('hide'); + $uploadingErrorMessage.html(message); }; - closeAlertMessage = function() { - return form.find(".div-dropzone-alert").alert("close"); - }; - form.find(".markdown-selector").click(function(e) { + + form.find('.markdown-selector').click(function(e) { e.preventDefault(); $(this).closest('.gfm-form').find('.div-dropzone').click(); - form_textarea.focus(); + formTextarea.focus(); }); } diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue index e0088d496eb..8120ef182d4 100644 --- a/app/assets/javascripts/environments/components/environment.vue +++ b/app/assets/javascripts/environments/components/environment.vue @@ -1,19 +1,28 @@ <script> /* global Flash */ +import Visibility from 'visibilityjs'; import EnvironmentsService from '../services/environments_service'; -import EnvironmentTable from './environments_table.vue'; +import environmentTable from './environments_table.vue'; import EnvironmentsStore from '../stores/environments_store'; -import TablePaginationComponent from '../../vue_shared/components/table_pagination'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import tablePagination from '../../vue_shared/components/table_pagination.vue'; import '../../lib/utils/common_utils'; import eventHub from '../event_hub'; +import Poll from '../../lib/utils/poll'; +import environmentsMixin from '../mixins/environments_mixin'; export default { components: { - 'environment-table': EnvironmentTable, - 'table-pagination': TablePaginationComponent, + environmentTable, + tablePagination, + loadingIcon, }, + mixins: [ + environmentsMixin, + ], + data() { const environmentsData = document.querySelector('#environments-list-view').dataset; const store = new EnvironmentsStore(); @@ -33,6 +42,7 @@ export default { projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath, newEnvironmentPath: environmentsData.newEnvironmentPath, helpPagePath: environmentsData.helpPagePath, + isMakingRequest: false, // Pagination Properties, paginationInformation: {}, @@ -63,17 +73,43 @@ export default { * Toggles loading property. */ created() { + const scope = gl.utils.getParameterByName('scope') || this.visibility; + const page = gl.utils.getParameterByName('page') || this.pageNumber; + this.service = new EnvironmentsService(this.endpoint); - this.fetchEnvironments(); + const poll = new Poll({ + resource: this.service, + method: 'get', + data: { scope, page }, + successCallback: this.successCallback, + errorCallback: this.errorCallback, + notificationCallback: (isMakingRequest) => { + this.isMakingRequest = isMakingRequest; + + // We need to verify if any folder is open to also fecth it + this.openFolders = this.store.getOpenFolders(); + }, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); - eventHub.$on('refreshEnvironments', this.fetchEnvironments); eventHub.$on('toggleFolder', this.toggleFolder); eventHub.$on('postAction', this.postAction); }, - beforeDestroyed() { - eventHub.$off('refreshEnvironments'); + beforeDestroy() { eventHub.$off('toggleFolder'); eventHub.$off('postAction'); }, @@ -102,29 +138,13 @@ export default { fetchEnvironments() { const scope = gl.utils.getParameterByName('scope') || this.visibility; - const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber; + const page = gl.utils.getParameterByName('page') || this.pageNumber; this.isLoading = true; - return this.service.get(scope, pageNumber) - .then(resp => ({ - headers: resp.headers, - body: resp.json(), - })) - .then((response) => { - this.store.storeAvailableCount(response.body.available_count); - this.store.storeStoppedCount(response.body.stopped_count); - this.store.storeEnvironments(response.body.environments); - this.store.setPagination(response.headers); - }) - .then(() => { - this.isLoading = false; - }) - .catch(() => { - this.isLoading = false; - // eslint-disable-next-line no-new - new Flash('An error occurred while fetching the environments.'); - }); + return this.service.get({ scope, page }) + .then(this.successCallback) + .catch(this.errorCallback); }, fetchChildEnvironments(folder, folderUrl) { @@ -144,9 +164,34 @@ export default { }, postAction(endpoint) { - this.service.postAction(endpoint) - .then(() => this.fetchEnvironments()) - .catch(() => new Flash('An error occured while making the request.')); + if (!this.isMakingRequest) { + this.isLoading = true; + + this.service.postAction(endpoint) + .then(() => this.fetchEnvironments()) + .catch(() => new Flash('An error occured while making the request.')); + } + }, + + successCallback(resp) { + this.saveData(resp); + + // If folders are open while polling we need to open them again + if (this.openFolders.length) { + this.openFolders.map((folder) => { + // TODO - Move this to the backend + const folderUrl = `${window.location.pathname}/folders/${folder.folderName}`; + + this.store.updateFolder(folder, 'isOpen', true); + return this.fetchChildEnvironments(folder, folderUrl); + }); + } + }, + + errorCallback() { + this.isLoading = false; + // eslint-disable-next-line no-new + new Flash('An error occurred while fetching the environments.'); }, }, }; @@ -185,15 +230,12 @@ export default { </div> </div> - <div class="content-list environments-container"> - <div - class="environments-list-loading text-center" - v-if="isLoading"> - - <i - class="fa fa-spinner fa-spin" - aria-hidden="true" /> - </div> + <div class="environments-container"> + <loading-icon + label="Loading environments" + size="3" + v-if="isLoading" + /> <div class="blank-state blank-state-no-icon" @@ -213,7 +255,7 @@ export default { v-if="canCreateEnvironmentParsed" :href="newEnvironmentPath" class="btn btn-create js-new-environment-button"> - New Environment + New environment </a> </div> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 63bffe8a998..a2448520a5f 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,6 +1,7 @@ <script> import playIconSvg from 'icons/_icon_play.svg'; import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -11,6 +12,10 @@ export default { }, }, + components: { + loadingIcon, + }, + data() { return { playIconSvg, @@ -61,10 +66,7 @@ export default { <i class="fa fa-caret-down" aria-hidden="true"/> - <i - v-if="isLoading" - class="fa fa-spinner fa-spin" - aria-hidden="true"/> + <loading-icon v-if="isLoading" /> </span> </button> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 0ffe9ea17fa..de2269118cd 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,5 +1,7 @@ <script> import Timeago from 'timeago.js'; +import _ from 'underscore'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import '../../lib/utils/text_utility'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; @@ -19,6 +21,7 @@ const timeagoInstance = new Timeago(); export default { components: { + userAvatarLink, 'commit-component': CommitComponent, 'actions-component': ActionsComponent, 'external-url-component': ExternalUrlComponent, @@ -59,7 +62,7 @@ export default { hasLastDeploymentKey() { if (this.model && this.model.last_deployment && - !this.$options.isObjectEmpty(this.model.last_deployment)) { + !_.isEmpty(this.model.last_deployment)) { return true; } return false; @@ -310,8 +313,8 @@ export default { */ deploymentHasUser() { return this.model && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.user); + !_.isEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment.user); }, /** @@ -322,8 +325,8 @@ export default { */ deploymentUser() { if (this.model && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.user)) { + !_.isEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment.user)) { return this.model.last_deployment.user; } return {}; @@ -338,8 +341,8 @@ export default { */ shouldRenderBuildName() { return !this.model.isFolder && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.deployable); + !_.isEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment.deployable); }, /** @@ -380,7 +383,7 @@ export default { */ shouldRenderDeploymentID() { return !this.model.isFolder && - !this.$options.isObjectEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment) && this.model.last_deployment.iid !== undefined; }, @@ -410,21 +413,6 @@ export default { }, }, - /** - * Helper to verify if certain given object are empty. - * Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty - * @param {Object} object - * @returns {Bollean} - */ - isObjectEmpty(object) { - for (const key in object) { // eslint-disable-line - if (hasOwnProperty.call(object, key)) { - return false; - } - } - return true; - }, - methods: { onClickFolder() { eventHub.$emit('toggleFolder', this.model, this.folderUrl); @@ -433,14 +421,21 @@ export default { }; </script> <template> - <tr :class="{ 'js-child-row': model.isChildren }"> - <td> + <div + :class="{ 'js-child-row environment-child-row': model.isChildren, 'folder-row': model.isFolder, 'gl-responsive-table-row': !model.isFolder }" + role="row"> + <div class="table-section section-10" role="gridcell"> + <div + v-if="!model.isFolder" + class="table-mobile-header" + role="rowheader"> + Environment + </div> <a v-if="!model.isFolder" - class="environment-name" - :class="{ 'prepend-left-default': model.isChildren }" + class="environment-name flex-truncate-parent table-mobile-content" :href="environmentPath"> - {{model.name}} + <span class="flex-truncate-child">{{model.name}}</span> </a> <span v-else @@ -473,40 +468,44 @@ export default { {{model.size}} </span> </span> - </td> + </div> - <td class="deployment-column"> + <div class="table-section section-10 deployment-column hidden-xs hidden-sm" role="gridcell"> <span v-if="shouldRenderDeploymentID"> {{deploymentInternalId}} </span> <span v-if="!model.isFolder && deploymentHasUser"> by - <a - :href="deploymentUser.web_url" - class="js-deploy-user-container"> - <img - class="avatar has-tooltip s20" - :src="deploymentUser.avatar_url" - :alt="userImageAltDescription" - :title="deploymentUser.username" /> - </a> + <user-avatar-link + class="js-deploy-user-container" + :link-href="deploymentUser.web_url" + :img-src="deploymentUser.avatar_url" + :img-alt="userImageAltDescription" + :tooltip-text="deploymentUser.username" + /> </span> - </td> + </div> - <td class="environments-build-cell"> + <div class="table-section section-15 hidden-xs hidden-sm" role="gridcell"> <a v-if="shouldRenderBuildName" class="build-link" :href="buildPath"> {{buildName}} </a> - </td> + </div> - <td> + <div class="table-section section-25" role="gridcell"> + <div + v-if="!model.isFolder" + role="rowheader" + class="table-mobile-header"> + Commit + </div> <div v-if="!model.isFolder && hasLastDeploymentKey" - class="js-commit-component"> + class="js-commit-component table-mobile-content"> <commit-component :tag="commitTag" :commit-ref="commitRef" @@ -515,25 +514,31 @@ export default { :title="commitTitle" :author="commitAuthor"/> </div> - <p + <div v-if="!model.isFolder && !hasLastDeploymentKey" - class="commit-title"> + class="commit-title table-mobile-content"> No deployments yet - </p> - </td> + </div> + </div> - <td> + <div class="table-section section-10" role="gridcell"> + <div + v-if="!model.isFolder" + role="rowheader" + class="table-mobile-header"> + Updated + </div> <span v-if="!model.isFolder && canShowDate" - class="environment-created-date-timeago"> + class="environment-created-date-timeago table-mobile-content"> {{createdDate}} </span> - </td> + </div> - <td class="environments-actions"> + <div class="table-section section-30 environments-actions table-button-footer" role="gridcell"> <div v-if="!model.isFolder" - class="btn-group pull-right" + class="btn-group environment-action-buttons" role="group"> <actions-component @@ -567,6 +572,6 @@ export default { :retry-url="retryUrl" /> </div> - </td> - </tr> + </div> + </div> </template> diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index 79c019b3491..07cf92281a0 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -19,7 +19,7 @@ export default { </script> <template> <a - class="btn monitoring-url has-tooltip" + class="btn monitoring-url has-tooltip hidden-xs hidden-sm" data-container="body" rel="noopener noreferrer nofollow" :href="monitoringUrl" diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 44b8730fd09..49dba38edfb 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -6,6 +6,7 @@ * Makes a post request when the button is clicked. */ import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -20,6 +21,10 @@ export default { }, }, + components: { + loadingIcon, + }, + data() { return { isLoading: false, @@ -38,7 +43,7 @@ export default { <template> <button type="button" - class="btn" + class="btn hidden-xs hidden-sm" @click="onClick" :disabled="isLoading"> @@ -49,9 +54,6 @@ export default { Rollback </span> - <i - v-if="isLoading" - class="fa fa-spinner fa-spin" - aria-hidden="true" /> + <loading-icon v-if="isLoading" /> </button> </template> diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index f483ea7e937..091c543860b 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -4,6 +4,7 @@ * Used in environments table. */ import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -19,6 +20,10 @@ export default { }; }, + components: { + loadingIcon, + }, + computed: { title() { return 'Stop'; @@ -42,7 +47,7 @@ export default { <template> <button type="button" - class="btn stop-env-link has-tooltip" + class="btn stop-env-link has-tooltip hidden-xs hidden-sm" data-container="body" @click="onClick" :disabled="isLoading" @@ -51,9 +56,6 @@ export default { <i class="fa fa-stop stop-env-icon" aria-hidden="true" /> - <i - v-if="isLoading" - class="fa fa-spinner fa-spin" - aria-hidden="true" /> + <loading-icon v-if="isLoading" /> </button> </template> diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index c8c1f17d4d8..1ca65a79951 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -29,7 +29,7 @@ export default { </script> <template> <a - class="btn terminal-button has-tooltip" + class="btn terminal-button has-tooltip hidden-xs hidden-sm" data-container="body" :title="title" :aria-label="title" diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 15eedaf76e1..b1fd9db650b 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -3,10 +3,12 @@ * Render environments table. */ import EnvironmentTableRowComponent from './environment_item.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { components: { 'environment-item': EnvironmentTableRowComponent, + loadingIcon, }, props: { @@ -43,70 +45,59 @@ export default { }; </script> <template> - <table class="table ci-table"> - <thead> - <tr> - <th class="environments-name"> - Environment - </th> - <th class="environments-deploy"> - Last deployment - </th> - <th class="environments-build"> - Job - </th> - <th class="environments-commit"> - Commit - </th> - <th class="environments-date"> - Updated - </th> - <th class="environments-actions"></th> - </tr> - </thead> - <tbody> - <template - v-for="model in environments" - v-bind:model="model"> - <tr - is="environment-item" - :model="model" - :can-create-deployment="canCreateDeployment" - :can-read-environment="canReadEnvironment" - /> + <div class="ci-table" role="grid"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-10 environments-name" role="columnheader"> + Environment + </div> + <div class="table-section section-10 environments-deploy" role="columnheader"> + Deployment + </div> + <div class="table-section section-15 environments-build" role="columnheader"> + Job + </div> + <div class="table-section section-25 environments-commit" role="columnheader"> + Commit + </div> + <div class="table-section section-10 environments-date" role="columnheader"> + Updated + </div> + </div> + <template + v-for="model in environments" + v-bind:model="model"> + <div + is="environment-item" + :model="model" + :can-create-deployment="canCreateDeployment" + :can-read-environment="canReadEnvironment" + /> - <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> - <tr v-if="isLoadingFolderContent"> - <td colspan="6" class="text-center"> - <i - class="fa fa-spin fa-spinner fa-2x" - aria-hidden="true" /> - </td> - </tr> + <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> + <div v-if="isLoadingFolderContent"> + <loading-icon size="2" /> + </div> - <template v-else> - <tr - is="environment-item" - v-for="children in model.children" - :model="children" - :can-create-deployment="canCreateDeployment" - :can-read-environment="canReadEnvironment" - /> + <template v-else> + <div + is="environment-item" + v-for="children in model.children" + :model="children" + :can-create-deployment="canCreateDeployment" + :can-read-environment="canReadEnvironment" + /> - <tr> - <td - colspan="6" - class="text-center"> - <a - :href="folderUrl(model)" - class="btn btn-default"> - Show all - </a> - </td> - </tr> - </template> + <div> + <div class="text-center prepend-top-10"> + <a + :href="folderUrl(model)" + class="btn btn-default"> + Show all + </a> + </div> + </div> </template> </template> - </tbody> - </table> + </template> + </div> </template> diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index f4a0c390c91..925503a01c4 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,18 +1,27 @@ <script> /* global Flash */ +import Visibility from 'visibilityjs'; import EnvironmentsService from '../services/environments_service'; -import EnvironmentTable from '../components/environments_table.vue'; +import environmentTable from '../components/environments_table.vue'; import EnvironmentsStore from '../stores/environments_store'; -import TablePaginationComponent from '../../vue_shared/components/table_pagination'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import tablePagination from '../../vue_shared/components/table_pagination.vue'; +import Poll from '../../lib/utils/poll'; +import eventHub from '../event_hub'; +import environmentsMixin from '../mixins/environments_mixin'; import '../../lib/utils/common_utils'; -import '../../vue_shared/vue_resource_interceptor'; export default { components: { - 'environment-table': EnvironmentTable, - 'table-pagination': TablePaginationComponent, + environmentTable, + tablePagination, + loadingIcon, }, + mixins: [ + environmentsMixin, + ], + data() { const environmentsData = document.querySelector('#environments-folder-list-view').dataset; const store = new EnvironmentsStore(); @@ -74,33 +83,39 @@ export default { */ created() { const scope = gl.utils.getParameterByName('scope') || this.visibility; - const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber; - - const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`; - - this.service = new EnvironmentsService(endpoint); - - this.isLoading = true; - - return this.service.get() - .then(resp => ({ - headers: resp.headers, - body: resp.json(), - })) - .then((response) => { - this.store.storeAvailableCount(response.body.available_count); - this.store.storeStoppedCount(response.body.stopped_count); - this.store.storeEnvironments(response.body.environments); - this.store.setPagination(response.headers); - }) - .then(() => { - this.isLoading = false; - }) - .catch(() => { - this.isLoading = false; - // eslint-disable-next-line no-new - new Flash('An error occurred while fetching the environments.', 'alert'); - }); + const page = gl.utils.getParameterByName('page') || this.pageNumber; + + this.service = new EnvironmentsService(this.endpoint); + + const poll = new Poll({ + resource: this.service, + method: 'get', + data: { scope, page }, + successCallback: this.successCallback, + errorCallback: this.errorCallback, + notificationCallback: (isMakingRequest) => { + this.isMakingRequest = isMakingRequest; + }, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + + eventHub.$on('postAction', this.postAction); + }, + + beforeDestroyed() { + eventHub.$off('postAction'); }, methods: { @@ -115,6 +130,37 @@ export default { gl.utils.visitUrl(param); return param; }, + + fetchEnvironments() { + const scope = gl.utils.getParameterByName('scope') || this.visibility; + const page = gl.utils.getParameterByName('page') || this.pageNumber; + + this.isLoading = true; + + return this.service.get({ scope, page }) + .then(this.successCallback) + .catch(this.errorCallback); + }, + + successCallback(resp) { + this.saveData(resp); + }, + + errorCallback() { + this.isLoading = false; + // eslint-disable-next-line no-new + new Flash('An error occurred while fetching the environments.'); + }, + + postAction(endpoint) { + if (!this.isMakingRequest) { + this.isLoading = true; + + this.service.postAction(endpoint) + .then(() => this.fetchEnvironments()) + .catch(() => new Flash('An error occured while making the request.')); + } + }, }, }; </script> @@ -153,13 +199,12 @@ export default { </div> <div class="environments-container"> - <div - class="environments-list-loading text-center" - v-if="isLoading"> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true"/> - </div> + + <loading-icon + label="Loading environments" + v-if="isLoading" + size="3" + /> <div class="table-holder" diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js new file mode 100644 index 00000000000..25b24fbd6dc --- /dev/null +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -0,0 +1,17 @@ +export default { + methods: { + saveData(resp) { + const response = { + headers: resp.headers, + body: resp.json(), + }; + + this.isLoading = false; + + this.store.storeAvailableCount(response.body.available_count); + this.store.storeStoppedCount(response.body.stopped_count); + this.store.storeEnvironments(response.body.environments); + this.store.setPagination(response.headers); + }, + }, +}; diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js index 8adb53ea86d..03ab74b3338 100644 --- a/app/assets/javascripts/environments/services/environments_service.js +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -10,7 +10,8 @@ export default class EnvironmentsService { this.folderResults = 3; } - get(scope, page) { + get(options = {}) { + const { scope, page } = options; return this.environments.get({ scope, page }); } diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 158e7922e3c..8a2f6a473de 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -153,4 +153,10 @@ export default class EnvironmentsStore { return updatedEnvironments; } + getOpenFolders() { + const environments = this.state.environments; + + return environments.filter(env => env.isFolder && env.isOpen); + } + } diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 59d6508fc02..534e651b030 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -3,7 +3,6 @@ /* global notes */ let $commentButtonTemplate; -var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; window.FilesCommentButton = (function() { var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS; @@ -27,8 +26,8 @@ window.FilesCommentButton = (function() { TEXT_FILE_SELECTOR = '.text-file'; function FilesCommentButton(filesContainerElement) { - this.render = bind(this.render, this); - this.hideButton = bind(this.hideButton, this); + this.render = this.render.bind(this); + this.hideButton = this.hideButton.bind(this); this.isParallelView = notes.isParallelView(); filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render) .on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton); diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js index 15052dbd362..c51d4b056af 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js @@ -13,13 +13,17 @@ export default { required: false, default: true, }, + allowedKeys: { + type: Array, + required: true, + }, }, computed: { processedItems() { return this.items.map((item) => { const { tokens, searchToken } - = gl.FilteredSearchTokenizer.processTokens(item); + = gl.FilteredSearchTokenizer.processTokens(item, this.allowedKeys); const resultantTokens = tokens.map(token => ({ prefix: `${token.key}:`, diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 5e9434fd48f..2af242a69df 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -1,16 +1,19 @@ import Filter from '~/droplab/plugins/filter'; - -require('./filtered_search_dropdown'); +import './filtered_search_dropdown'; class DropdownHint extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter) { + constructor(droplab, dropdown, input, tokenKeys, filter) { super(droplab, dropdown, input, filter); this.config = { Filter: { template: 'hint', - filterFunction: gl.DropdownUtils.filterHint.bind(null, input), + filterFunction: gl.DropdownUtils.filterHint.bind(null, { + input, + allowedKeys: tokenKeys.getKeys(), + }), }, }; + this.tokenKeys = tokenKeys; } itemClicked(e) { @@ -53,20 +56,13 @@ class DropdownHint extends gl.FilteredSearchDropdown { } renderContent() { - const dropdownData = []; - - [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { - const { icon, hint, tag, type } = dropdownMenu.dataset; - if (icon && hint && tag) { - dropdownData.push( - Object.assign({ - icon: `fa-${icon}`, - hint, - tag: `<${tag}>`, - }, type && { type }), - ); - } - }); + const dropdownData = gl.FilteredSearchTokenKeys.get() + .map(tokenKey => ({ + icon: `fa-${tokenKey.icon}`, + hint: tokenKey.key, + tag: `<${tokenKey.symbol}${tokenKey.key}>`, + type: tokenKey.type, + })); this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); this.droplab.setData(this.hookId, dropdownData); diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index 982dc4b61be..34a9e34070c 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -2,11 +2,10 @@ import Ajax from '~/droplab/plugins/ajax'; import Filter from '~/droplab/plugins/filter'; - -require('./filtered_search_dropdown'); +import './filtered_search_dropdown'; class DropdownNonUser extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter, endpoint, symbol) { + constructor(droplab, dropdown, input, tokenKeys, filter, endpoint, symbol) { super(droplab, dropdown, input, filter); this.symbol = symbol; this.config = { diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index 74cec3d75fe..65c1b2050ac 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -1,11 +1,10 @@ /* global Flash */ import AjaxFilter from '~/droplab/plugins/ajax_filter'; - -require('./filtered_search_dropdown'); +import './filtered_search_dropdown'; class DropdownUser extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter) { + constructor(droplab, dropdown, input, tokenKeys, filter) { super(droplab, dropdown, input, filter); this.config = { AjaxFilter: { @@ -19,6 +18,9 @@ class DropdownUser extends gl.FilteredSearchDropdown { }, searchValueFunction: this.getSearchInput.bind(this), loadingTemplate: this.loadingTemplate, + onLoadingFinished: () => { + this.hideCurrentUser(); + }, onError() { /* eslint-disable no-new */ new Flash('An error occured fetching the dropdown data.'); @@ -26,6 +28,12 @@ class DropdownUser extends gl.FilteredSearchDropdown { }, }, }; + this.tokenKeys = tokenKeys; + } + + hideCurrentUser() { + const currenUserItem = this.dropdown.querySelector('.js-current-user'); + currenUserItem.classList.add('hidden'); } itemClicked(e) { @@ -44,7 +52,7 @@ class DropdownUser extends gl.FilteredSearchDropdown { getSearchInput() { const query = gl.DropdownUtils.getSearchInput(this.input); - const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); + const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get()); let value = lastToken || ''; diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index bc7c1dffece..ef8fe071012 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -50,10 +50,12 @@ class DropdownUtils { return updatedItem; } - static filterHint(input, item) { + static filterHint(config, item) { + const { input, allowedKeys } = config; const updatedItem = item; const searchInput = gl.DropdownUtils.getSearchQuery(input); - const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput); + const { lastToken, tokens } = + gl.FilteredSearchTokenizer.processTokens(searchInput, allowedKeys); const lastKey = lastToken.key || lastToken || ''; const allowMultiple = item.type === 'array'; const itemInExistingTokens = tokens.some(t => t.key === item.hint); @@ -100,10 +102,13 @@ class DropdownUtils { if (token.classList.contains('js-visual-token')) { const name = token.querySelector('.name'); const value = token.querySelector('.value'); + const valueContainer = token.querySelector('.value-container'); const symbol = value && value.dataset.symbol ? value.dataset.symbol : ''; let valueText = ''; - if (value && value.innerText) { + if (valueContainer && valueContainer.dataset.originalValue) { + valueText = valueContainer.dataset.originalValue; + } else if (value && value.innerText) { valueText = value.innerText; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js index 856eb6590ee..132b6fe698a 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js +++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js @@ -1,10 +1,10 @@ -require('./dropdown_hint'); -require('./dropdown_non_user'); -require('./dropdown_user'); -require('./dropdown_utils'); -require('./filtered_search_dropdown_manager'); -require('./filtered_search_dropdown'); -require('./filtered_search_manager'); -require('./filtered_search_token_keys'); -require('./filtered_search_tokenizer'); -require('./filtered_search_visual_tokens'); +import './dropdown_hint'; +import './dropdown_non_user'; +import './dropdown_user'; +import './dropdown_utils'; +import './filtered_search_token_keys'; +import './filtered_search_dropdown_manager'; +import './filtered_search_dropdown'; +import './filtered_search_manager'; +import './filtered_search_tokenizer'; +import './filtered_search_visual_tokens'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 49a6cd1ac77..6bc6bc43f51 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -2,10 +2,10 @@ import DropLab from '~/droplab/drop_lab'; import FilteredSearchContainer from './container'; class FilteredSearchDropdownManager { - constructor(baseEndpoint = '', page) { + constructor(baseEndpoint = '', tokenizer, page) { this.container = FilteredSearchContainer.container; this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); - this.tokenizer = gl.FilteredSearchTokenizer; + this.tokenizer = tokenizer; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.page = page; @@ -98,7 +98,8 @@ class FilteredSearchDropdownManager { if (!mappingKey.reference) { const dl = this.droplab; - const defaultArguments = [null, dl, element, this.filteredSearchInput, key]; + const defaultArguments = + [null, dl, element, this.filteredSearchInput, this.filteredSearchTokenKeys, key]; const glArguments = defaultArguments.concat(mappingKey.extraArguments || []); // Passing glArguments to `new gl[glClass](<arguments>)` @@ -141,7 +142,8 @@ class FilteredSearchDropdownManager { setDropdown() { const query = gl.DropdownUtils.getSearchQuery(true); - const { lastToken, searchToken } = this.tokenizer.processTokens(query); + const { lastToken, searchToken } = + this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys()); if (this.currentDropdown) { this.updateCurrentDropdownOffset(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 9fea563370f..8f547bd8f1f 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -6,6 +6,7 @@ import eventHub from './event_hub'; class FilteredSearchManager { constructor(page) { + this.page = page; this.container = FilteredSearchContainer.container; this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInputForm = this.filteredSearchInput.form; @@ -15,13 +16,20 @@ class FilteredSearchManager { this.recentSearchesStore = new RecentSearchesStore({ isLocalStorageAvailable: RecentSearchesService.isAvailable(), + allowedKeys: this.filteredSearchTokenKeys.getKeys(), }); - let recentSearchesKey = 'issue-recent-searches'; - if (page === 'merge_requests') { - recentSearchesKey = 'merge-request-recent-searches'; + this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); + const projectPath = this.searchHistoryDropdownElement ? + this.searchHistoryDropdownElement.dataset.projectFullPath : 'project'; + let recentSearchesPagePrefix = 'issue-recent-searches'; + if (this.page === 'merge_requests') { + recentSearchesPagePrefix = 'merge-request-recent-searches'; } + const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`; this.recentSearchesService = new RecentSearchesService(recentSearchesKey); + } + setup() { // Fetch recent searches from localStorage this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() .catch((error) => { @@ -42,12 +50,12 @@ class FilteredSearchManager { if (this.filteredSearchInput) { this.tokenizer = gl.FilteredSearchTokenizer; - this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); + this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page); this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesStore, this.recentSearchesService, - document.querySelector('.js-filtered-search-history-dropdown'), + this.searchHistoryDropdownElement, ); this.recentSearchesRoot.init(); @@ -69,6 +77,41 @@ class FilteredSearchManager { } } + bindStateEvents() { + this.stateFilters = document.querySelector('.container-fluid .issues-state-filters'); + + if (this.stateFilters) { + this.searchStateWrapper = this.searchState.bind(this); + + this.stateFilters.querySelector('[data-state="opened"]') + .addEventListener('click', this.searchStateWrapper); + this.stateFilters.querySelector('[data-state="closed"]') + .addEventListener('click', this.searchStateWrapper); + this.stateFilters.querySelector('[data-state="all"]') + .addEventListener('click', this.searchStateWrapper); + + this.mergedState = this.stateFilters.querySelector('[data-state="merged"]'); + if (this.mergedState) { + this.mergedState.addEventListener('click', this.searchStateWrapper); + } + } + } + + unbindStateEvents() { + if (this.stateFilters) { + this.stateFilters.querySelector('[data-state="opened"]') + .removeEventListener('click', this.searchStateWrapper); + this.stateFilters.querySelector('[data-state="closed"]') + .removeEventListener('click', this.searchStateWrapper); + this.stateFilters.querySelector('[data-state="all"]') + .removeEventListener('click', this.searchStateWrapper); + + if (this.mergedState) { + this.mergedState.removeEventListener('click', this.searchStateWrapper); + } + } + } + bindEvents() { this.handleFormSubmit = this.handleFormSubmit.bind(this); this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager); @@ -97,15 +140,15 @@ class FilteredSearchManager { this.filteredSearchInput.addEventListener('click', this.tokenChange); this.filteredSearchInput.addEventListener('keyup', this.tokenChange); this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); - this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.addEventListener('click', this.removeTokenWrapper); - this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); + this.tokensContainer.addEventListener('click', this.editTokenWrapper); this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper); - document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.addEventListener('click', this.unselectEditTokensWrapper); document.addEventListener('click', this.removeInputContainerFocusWrapper); document.addEventListener('keydown', this.removeSelectedTokenKeydownWrapper); eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); + + this.bindStateEvents(); } unbindEvents() { @@ -119,15 +162,15 @@ class FilteredSearchManager { this.filteredSearchInput.removeEventListener('click', this.tokenChange); this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); - this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.removeEventListener('click', this.removeTokenWrapper); - this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); + this.tokensContainer.removeEventListener('click', this.editTokenWrapper); this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper); - document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.removeEventListener('click', this.unselectEditTokensWrapper); document.removeEventListener('click', this.removeInputContainerFocusWrapper); document.removeEventListener('keydown', this.removeSelectedTokenKeydownWrapper); eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); + + this.unbindStateEvents(); } checkForBackspace(e) { @@ -136,7 +179,9 @@ class FilteredSearchManager { if (e.keyCode === 8 || e.keyCode === 46) { const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - if (this.filteredSearchInput.value === '' && lastVisualToken) { + const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim(); + const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName); + if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) { this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial(); gl.FilteredSearchVisualTokens.removeLastTokenPartial(); } @@ -197,23 +242,13 @@ class FilteredSearchManager { } } - static selectToken(e) { - const button = e.target.closest('.selectable'); - const removeButtonSelected = e.target.closest('.remove-token'); - - if (!removeButtonSelected && button) { - e.preventDefault(); - e.stopPropagation(); - gl.FilteredSearchVisualTokens.selectToken(button); - } - } - removeToken(e) { const removeButtonSelected = e.target.closest('.remove-token'); if (removeButtonSelected) { e.preventDefault(); - e.stopPropagation(); + // Prevent editToken from being triggered after token is removed + e.stopImmediatePropagation(); const button = e.target.closest('.selectable'); gl.FilteredSearchVisualTokens.selectToken(button, true); @@ -235,8 +270,12 @@ class FilteredSearchManager { editToken(e) { const token = e.target.closest('.js-visual-token'); + const sanitizedTokenName = token && token.querySelector('.name').textContent.trim(); + const canEdit = this.canEdit && this.canEdit(sanitizedTokenName); - if (token) { + if (token && canEdit) { + e.preventDefault(); + e.stopPropagation(); gl.FilteredSearchVisualTokens.editToken(token); this.tokenChange(); } @@ -314,7 +353,7 @@ class FilteredSearchManager { handleInputVisualToken() { const input = this.filteredSearchInput; const { tokens, searchToken } - = gl.FilteredSearchTokenizer.processTokens(input.value); + = this.tokenizer.processTokens(input.value, this.filteredSearchTokenKeys.getKeys()); const { isLastVisualTokenValid } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); @@ -386,7 +425,12 @@ class FilteredSearchManager { if (condition) { hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value); + const canEdit = this.canEdit && this.canEdit(condition.tokenKey); + gl.FilteredSearchVisualTokens.addFilterVisualToken( + condition.tokenKey, + condition.value, + canEdit, + ); } else { // Sanitize value since URL converts spaces into + // Replace before decode so that we know what was originally + versus the encoded + @@ -405,18 +449,27 @@ class FilteredSearchManager { } hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`); + const canEdit = this.canEdit && this.canEdit(sanitizedKey); + gl.FilteredSearchVisualTokens.addFilterVisualToken( + sanitizedKey, + `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, + canEdit, + ); } else if (!match && keyParam === 'assignee_id') { const id = parseInt(value, 10); if (usernameParams[id]) { hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`); + const tokenName = 'assignee'; + const canEdit = this.canEdit && this.canEdit(tokenName); + gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); } } else if (!match && keyParam === 'author_id') { const id = parseInt(value, 10); if (usernameParams[id]) { hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`); + const tokenName = 'author'; + const canEdit = this.canEdit && this.canEdit(tokenName); + gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); } } else if (!match && keyParam === 'search') { hasFilteredSearch = true; @@ -433,15 +486,27 @@ class FilteredSearchManager { } } - search() { + searchState(e) { + const target = e.currentTarget; + // remove focus outline after click + target.blur(); + + const state = target.dataset && target.dataset.state; + + if (state) { + this.search(state); + } + } + + search(state = null) { const paths = []; const searchQuery = gl.DropdownUtils.getSearchQuery(); this.saveCurrentSearchQuery(); const { tokens, searchToken } - = this.tokenizer.processTokens(searchQuery); - const currentState = gl.utils.getParameterByName('state') || 'opened'; + = this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys()); + const currentState = state || gl.utils.getParameterByName('state') || 'opened'; paths.push(`state=${currentState}`); tokens.forEach((token) => { @@ -511,6 +576,11 @@ class FilteredSearchManager { this.filteredSearchInput.dispatchEvent(new CustomEvent('input')); this.search(); } + + // eslint-disable-next-line class-methods-use-this + canEdit() { + return true; + } } window.gl = window.gl || {}; diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index 1abad9d1b73..025d4d8795b 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -3,21 +3,25 @@ const tokenKeys = [{ type: 'string', param: 'username', symbol: '@', + icon: 'pencil', }, { key: 'assignee', type: 'string', param: 'username', symbol: '@', + icon: 'user', }, { key: 'milestone', type: 'string', param: 'title', symbol: '%', + icon: 'clock-o', }, { key: 'label', type: 'array', param: 'name[]', symbol: '~', + icon: 'tag', }]; const alternativeTokenKeys = [{ @@ -56,6 +60,10 @@ class FilteredSearchTokenKeys { return tokenKeys; } + static getKeys() { + return tokenKeys.map(i => i.key); + } + static getAlternatives() { return alternativeTokenKeys; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js index 2808e4b238a..f2e66503e5e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js +++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js @@ -1,8 +1,7 @@ -require('./filtered_search_token_keys'); +import './filtered_search_token_keys'; class FilteredSearchTokenizer { - static processTokens(input) { - const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key); + static processTokens(input, allowedKeys) { // Regex extracts `(token):(symbol)(value)` // Values that start with a double quote must end in a double quote (same for single) const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g'); diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index f3003b86493..e9278140af0 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,6 +1,7 @@ -import AjaxCache from '~/lib/utils/ajax_cache'; -import '~/flash'; /* global Flash */ +import AjaxCache from '../lib/utils/ajax_cache'; +import '../flash'; /* global Flash */ import FilteredSearchContainer from './container'; +import UsersCache from '../lib/utils/users_cache'; class FilteredSearchVisualTokens { static getLastVisualTokenBeforeInput() { @@ -36,15 +37,22 @@ class FilteredSearchVisualTokens { } } - static createVisualTokenElementHTML() { + static createVisualTokenElementHTML(canEdit = true) { + let removeTokenMarkup = ''; + if (canEdit) { + removeTokenMarkup = ` + <div class="remove-token" role="button"> + <i class="fa fa-close"></i> + </div> + `; + } + return ` <div class="selectable" role="button"> <div class="name"></div> <div class="value-container"> <div class="value"></div> - <div class="remove-token" role="button"> - <i class="fa fa-close"></i> - </div> + ${removeTokenMarkup} </div> </div> `; @@ -75,22 +83,52 @@ class FilteredSearchVisualTokens { .catch(() => new Flash('An error occurred while fetching label colors.')); } + static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) { + if (tokenValue === 'none') { + return Promise.resolve(); + } + + const username = tokenValue.replace(/^@/, ''); + return UsersCache.retrieve(username) + .then((user) => { + if (!user) { + return; + } + + /* eslint-disable no-param-reassign */ + tokenValueContainer.dataset.originalValue = tokenValue; + tokenValueElement.innerHTML = ` + <img class="avatar s20" src="${user.avatar_url}" alt="${user.name}'s avatar"> + ${user.name} + `; + /* eslint-enable no-param-reassign */ + }) + // ignore error and leave username in the search bar + .catch(() => { }); + } + static renderVisualTokenValue(parentElement, tokenName, tokenValue) { const tokenValueContainer = parentElement.querySelector('.value-container'); - tokenValueContainer.querySelector('.value').innerText = tokenValue; + const tokenValueElement = tokenValueContainer.querySelector('.value'); + tokenValueElement.innerText = tokenValue; - if (tokenName.toLowerCase() === 'label') { + const tokenType = tokenName.toLowerCase(); + if (tokenType === 'label') { FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); + } else if ((tokenType === 'author') || (tokenType === 'assignee')) { + FilteredSearchVisualTokens.updateUserTokenAppearance( + tokenValueContainer, tokenValueElement, tokenValue, + ); } } - static addVisualTokenElement(name, value, isSearchTerm) { + static addVisualTokenElement(name, value, isSearchTerm, canEdit) { const li = document.createElement('li'); li.classList.add('js-visual-token'); li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); if (value) { - li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); + li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit); FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); } else { li.innerHTML = '<div class="name"></div>'; @@ -114,20 +152,20 @@ class FilteredSearchVisualTokens { } } - static addFilterVisualToken(tokenName, tokenValue) { + static addFilterVisualToken(tokenName, tokenValue, canEdit) { const { lastVisualToken, isLastVisualTokenValid } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement; if (isLastVisualTokenValid) { - addVisualTokenElement(tokenName, tokenValue, false); + addVisualTokenElement(tokenName, tokenValue, false, canEdit); } else { const previousTokenName = lastVisualToken.querySelector('.name').innerText; const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); tokensContainer.removeChild(lastVisualToken); const value = tokenValue || tokenName; - addVisualTokenElement(previousTokenName, value, false); + addVisualTokenElement(previousTokenName, value, false, canEdit); } } @@ -146,6 +184,12 @@ class FilteredSearchVisualTokens { if (!lastVisualToken) return ''; + const valueContainer = lastVisualToken.querySelector('.value-container'); + const originalValue = valueContainer && valueContainer.dataset.originalValue; + if (originalValue) { + return originalValue; + } + const value = lastVisualToken.querySelector('.value'); const name = lastVisualToken.querySelector('.name'); @@ -198,17 +242,28 @@ class FilteredSearchVisualTokens { const inputLi = input.parentElement; tokenContainer.replaceChild(inputLi, token); - const name = token.querySelector('.name'); - const value = token.querySelector('.value'); + const nameElement = token.querySelector('.name'); + let value; - if (token.classList.contains('filtered-search-token') && value) { - FilteredSearchVisualTokens.addFilterVisualToken(name.innerText); - input.value = value.innerText; - } else { - // token is a search term - input.value = name.innerText; + if (token.classList.contains('filtered-search-token')) { + FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText); + + const valueContainerElement = token.querySelector('.value-container'); + value = valueContainerElement.dataset.originalValue; + + if (!value) { + const valueElement = valueContainerElement.querySelector('.value'); + value = valueElement.innerText; + } + } + + // token is a search term + if (!value) { + value = nameElement.innerText; } + input.value = value; + // Opens dropdown const inputEvent = new Event('input'); input.dispatchEvent(inputEvent); diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js index b2e6f63aacf..27e49d4fb96 100644 --- a/app/assets/javascripts/filtered_search/recent_searches_root.js +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -37,6 +37,7 @@ class RecentSearchesRoot { <recent-searches-dropdown-content :items="recentSearches" :is-local-storage-available="isLocalStorageAvailable" + :allowed-keys="allowedKeys" /> `, components: { diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js index 066be69766a..aaa0c349d93 100644 --- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js +++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js @@ -1,9 +1,11 @@ import _ from 'underscore'; class RecentSearchesStore { - constructor(initialState = {}) { + constructor(initialState = {}, allowedKeys) { this.state = Object.assign({ + isLocalStorageAvailable: true, recentSearches: [], + allowedKeys, }, initialState); } diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index eec30624ff2..ccff8f0ace7 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -7,8 +7,21 @@ window.Flash = (function() { return $(this).fadeOut(); }; - function Flash(message, type, parent) { - var flash, textDiv; + /** + * Flash banner supports different types of Flash configurations + * along with ability to provide actionConfig which can be used to show + * additional action or link on banner next to message + * + * @param {String} message Flash message + * @param {String} type Type of Flash, it can be `notice` or `alert` (default) + * @param {Object} parent Reference to Parent element under which Flash needs to appear + * @param {Object} actionConfig Map of config to show action on banner + * @param {String} href URL to which action link should point (default '#') + * @param {String} title Title of action + * @param {Function} clickHandler Method to call when action is clicked on + */ + function Flash(message, type, parent, actionConfig) { + var flash, textDiv, actionLink; if (type == null) { type = 'alert'; } @@ -30,6 +43,23 @@ window.Flash = (function() { text: message }); textDiv.appendTo(flash); + + if (actionConfig) { + const actionLinkConfig = { + class: 'flash-action', + href: actionConfig.href || '#', + text: actionConfig.title + }; + + if (!actionConfig.href) { + actionLinkConfig.role = 'button'; + } + + actionLink = $('<a/>', actionLinkConfig); + + actionLink.appendTo(flash); + this.flashContainer.on('click', '.flash-action', actionConfig.clickHandler); + } if (this.flashContainer.parent().hasClass('content-wrapper')) { textDiv.addClass('container-fluid container-limited'); } diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index f1b99023c72..401dec1a370 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,119 +1,34 @@ -/* eslint-disable func-names, space-before-function-paren, no-template-curly-in-string, comma-dangle, object-shorthand, quotes, dot-notation, no-else-return, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-param-reassign, no-useless-escape, prefer-template, consistent-return, wrap-iife, prefer-arrow-callback, camelcase, no-unused-vars, no-useless-return, vars-on-top, max-len */ - import emojiMap from 'emojis/digests.json'; import emojiAliases from 'emojis/aliases.json'; import { glEmojiTag } from '~/behaviors/gl_emoji'; import glRegexp from '~/lib/utils/regexp'; - -// Creates the variables for setting up GFM auto-completion -window.gl = window.gl || {}; +import AjaxCache from '~/lib/utils/ajax_cache'; function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); } -window.gl.GfmAutoComplete = { - dataSources: {}, - defaultLoadingData: ['loading'], - cachedData: {}, - isLoadingData: {}, - atTypeMap: { - ':': 'emojis', - '@': 'members', - '#': 'issues', - '!': 'mergeRequests', - '~': 'labels', - '%': 'milestones', - '/': 'commands' - }, - // Emoji - Emoji: { - templateFunction: function(name) { - return `<li> - ${name} ${glEmojiTag(name)} - </li> - `; - } - }, - // Team Members - Members: { - template: '<li>${avatarTag} ${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>' - }, - Loading: { - template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>' - }, - DefaultOptions: { - sorter: function(query, items, searchKey) { - this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0; - if (gl.GfmAutoComplete.isLoading(items)) { - this.setting.highlightFirst = false; - return items; - } - return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey); - }, - filter: function(query, data, searchKey) { - if (gl.GfmAutoComplete.isLoading(data)) { - gl.GfmAutoComplete.fetchData(this.$inputor, this.at); - return data; - } else { - return $.fn.atwho["default"].callbacks.filter(query, data, searchKey); - } - }, - beforeInsert: function(value) { - if (value && !this.setting.skipSpecialCharacterTest) { - var withoutAt = value.substring(1); - if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"'; - } - return value; - }, - matcher: function (flag, subtext) { - // The below is taken from At.js source - // Tweaked to commands to start without a space only if char before is a non-word character - // https://github.com/ichord/At.js - var _a, _y, regexp, match, atSymbolsWithBar, atSymbolsWithoutBar; - atSymbolsWithBar = Object.keys(this.app.controllers).join('|'); - atSymbolsWithoutBar = Object.keys(this.app.controllers).join(''); - subtext = subtext.split(/\s+/g).pop(); - flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); - - _a = decodeURI("%C3%80"); - _y = decodeURI("%C3%BF"); - - regexp = new RegExp("^(?:\\B|[^a-zA-Z0-9_" + atSymbolsWithoutBar + "]|\\s)" + flag + "(?!" + atSymbolsWithBar + ")((?:[A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]|[^\\x00-\\x7a])*)$", 'gi'); - - match = regexp.exec(subtext); +class GfmAutoComplete { + constructor(dataSources) { + this.dataSources = dataSources || {}; + this.cachedData = {}; + this.isLoadingData = {}; + } - if (match) { - return match[1]; - } else { - return null; - } - } - }, - setup: function(input, enableMap = { + setup(input, enableMap = { emojis: true, members: true, issues: true, milestones: true, mergeRequests: true, - labels: true + labels: true, }) { // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); this.enableMap = enableMap; this.setupLifecycle(); - }, + } + setupLifecycle() { this.input.each((i, input) => { const $input = $(input); @@ -121,10 +36,11 @@ window.gl.GfmAutoComplete = { // This triggers at.js again // Needed for slash commands with suffixes (ex: /label ~) $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); + $input.on('clear-commands-cache.atwho', () => this.clearCache()); }); - }, + } - setupAtWho: function($input) { + setupAtWho($input) { if (this.enableMap.emojis) this.setupEmoji($input); if (this.enableMap.members) this.setupMembers($input); if (this.enableMap.issues) this.setupIssues($input); @@ -138,10 +54,11 @@ window.gl.GfmAutoComplete = { alias: 'commands', searchKey: 'search', skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - displayTpl: function(value) { - if (this.isLoading(value)) return this.Loading.template; - var tpl = '<li>/${name}'; + data: GfmAutoComplete.defaultLoadingData, + displayTpl(value) { + if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template; + // eslint-disable-next-line no-template-curly-in-string + let tpl = '<li>/${name}'; if (value.aliases.length > 0) { tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; } @@ -153,105 +70,106 @@ window.gl.GfmAutoComplete = { } tpl += '</li>'; return _.template(tpl)(value); - }.bind(this), - insertTpl: function(value) { - var tpl = "/${name} "; - var reference_prefix = null; + }, + insertTpl(value) { + // eslint-disable-next-line no-template-curly-in-string + let tpl = '/${name} '; + let referencePrefix = null; if (value.params.length > 0) { - reference_prefix = value.params[0][0]; - if (/^[@%~]/.test(reference_prefix)) { - tpl += '<%- reference_prefix %>'; + referencePrefix = value.params[0][0]; + if (/^[@%~]/.test(referencePrefix)) { + tpl += '<%- referencePrefix %>'; } } - return _.template(tpl)({ reference_prefix: reference_prefix }); + return _.template(tpl)({ referencePrefix }); }, suffix: '', callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - beforeSave: function(commands) { - if (gl.GfmAutoComplete.isLoading(commands)) return commands; - return $.map(commands, function(c) { - var search = c.name; + ...this.getDefaultCallbacks(), + beforeSave(commands) { + if (GfmAutoComplete.isLoading(commands)) return commands; + return $.map(commands, (c) => { + let search = c.name; if (c.aliases.length > 0) { - search = search + " " + c.aliases.join(" "); + search = `${search} ${c.aliases.join(' ')}`; } return { name: c.name, aliases: c.aliases, params: c.params, description: c.description, - search: search + search, }; }); }, - matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { - var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; - var match = regexp.exec(subtext); + matcher(flag, subtext) { + const regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; + const match = regexp.exec(subtext); if (match) { return match[1]; - } else { - return null; } - } - } + return null; + }, + }, }); - return; - }, + } setupEmoji($input) { // Emoji $input.atwho({ at: ':', - displayTpl: function(value) { - return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template; - }.bind(this), + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value && value.name) { + tmpl = GfmAutoComplete.Emoji.templateFunction(value.name); + } + return tmpl; + }, + // eslint-disable-next-line no-template-curly-in-string insertTpl: ':${name}:', skipSpecialCharacterTest: true, - data: this.defaultLoadingData, + data: GfmAutoComplete.defaultLoadingData, callbacks: { - sorter: this.DefaultOptions.sorter, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter, - - matcher: (flag, subtext) => { + ...this.getDefaultCallbacks(), + matcher(flag, subtext) { const relevantText = subtext.trim().split(/\s/).pop(); const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi'); const match = regexp.exec(relevantText); return match && match.length ? match[1] : null; - } - } + }, + }, }); - }, + } setupMembers($input) { // Team Members $input.atwho({ at: '@', - displayTpl: function(value) { - return value.username != null ? this.Members.template : this.Loading.template; - }.bind(this), + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.username != null) { + tmpl = GfmAutoComplete.Members.template; + } + return tmpl; + }, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${username}', searchKey: 'search', alwaysHighlightFirst: true, skipSpecialCharacterTest: true, - data: this.defaultLoadingData, + data: GfmAutoComplete.defaultLoadingData, callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(members) { - return $.map(members, function(m) { + ...this.getDefaultCallbacks(), + beforeSave(members) { + return $.map(members, (m) => { let title = ''; if (m.username == null) { return m; } title = m.name; if (m.count) { - title += " (" + m.count + ")"; + title += ` (${m.count})`; } const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase(); @@ -262,173 +180,278 @@ window.gl.GfmAutoComplete = { username: m.username, avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar, title: sanitize(title), - search: sanitize(m.username + " " + m.name) + search: sanitize(`${m.username} ${m.name}`), }; }); - } - } + }, + }, }); - }, + } setupIssues($input) { $input.atwho({ at: '#', alias: 'issues', searchKey: 'search', - displayTpl: function(value) { - return value.title != null ? this.Issues.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.title != null) { + tmpl = GfmAutoComplete.Issues.template; + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${id}', callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(issues) { - return $.map(issues, function(i) { + ...this.getDefaultCallbacks(), + beforeSave(issues) { + return $.map(issues, (i) => { if (i.title == null) { return i; } return { id: i.iid, title: sanitize(i.title), - search: i.iid + " " + i.title + search: `${i.iid} ${i.title}`, }; }); - } - } + }, + }, }); - }, + } setupMilestones($input) { $input.atwho({ at: '%', alias: 'milestones', searchKey: 'search', + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${title}', - displayTpl: function(value) { - return value.title != null ? this.Milestones.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.title != null) { + tmpl = GfmAutoComplete.Milestones.template; + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, callbacks: { - matcher: this.DefaultOptions.matcher, - sorter: this.DefaultOptions.sorter, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter, - beforeSave: function(milestones) { - return $.map(milestones, function(m) { + ...this.getDefaultCallbacks(), + beforeSave(milestones) { + return $.map(milestones, (m) => { if (m.title == null) { return m; } return { id: m.iid, title: sanitize(m.title), - search: "" + m.title + search: m.title, }; }); - } - } + }, + }, }); - }, + } setupMergeRequests($input) { $input.atwho({ at: '!', alias: 'mergerequests', searchKey: 'search', - displayTpl: function(value) { - return value.title != null ? this.Issues.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.title != null) { + tmpl = GfmAutoComplete.Issues.template; + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${id}', callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(merges) { - return $.map(merges, function(m) { + ...this.getDefaultCallbacks(), + beforeSave(merges) { + return $.map(merges, (m) => { if (m.title == null) { return m; } return { id: m.iid, title: sanitize(m.title), - search: m.iid + " " + m.title + search: `${m.iid} ${m.title}`, }; }); - } - } + }, + }, }); - }, + } setupLabels($input) { $input.atwho({ at: '~', alias: 'labels', searchKey: 'search', - data: this.defaultLoadingData, - displayTpl: function(value) { - return this.isLoading(value) ? this.Loading.template : this.Labels.template; - }.bind(this), + data: GfmAutoComplete.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Labels.template; + if (GfmAutoComplete.isLoading(value)) { + tmpl = GfmAutoComplete.Loading.template; + } + return tmpl; + }, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${title}', callbacks: { - matcher: this.DefaultOptions.matcher, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter, - sorter: this.DefaultOptions.sorter, - beforeSave: function(merges) { - if (gl.GfmAutoComplete.isLoading(merges)) return merges; - var sanitizeLabelTitle; - sanitizeLabelTitle = function(title) { - if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) { - return "\"" + (sanitize(title)) + "\""; - } else { - return sanitize(title); - } - }; - return $.map(merges, function(m) { - return { - title: sanitize(m.title), - color: m.color, - search: "" + m.title - }; - }); - } - } + ...this.getDefaultCallbacks(), + beforeSave(merges) { + if (GfmAutoComplete.isLoading(merges)) return merges; + return $.map(merges, m => ({ + title: sanitize(m.title), + color: m.color, + search: m.title, + })); + }, + }, }); - }, + } + + getDefaultCallbacks() { + const fetchData = this.fetchData.bind(this); + + return { + sorter(query, items, searchKey) { + this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0; + if (GfmAutoComplete.isLoading(items)) { + this.setting.highlightFirst = false; + return items; + } + return $.fn.atwho.default.callbacks.sorter(query, items, searchKey); + }, + filter(query, data, searchKey) { + if (GfmAutoComplete.isLoading(data)) { + fetchData(this.$inputor, this.at); + return data; + } + return $.fn.atwho.default.callbacks.filter(query, data, searchKey); + }, + beforeInsert(value) { + let resultantValue = value; + if (value && !this.setting.skipSpecialCharacterTest) { + const withoutAt = value.substring(1); + if (withoutAt && /[^\w\d]/.test(withoutAt)) { + resultantValue = `${value.charAt()}"${withoutAt}"`; + } + } + return resultantValue; + }, + matcher(flag, subtext) { + // The below is taken from At.js source + // Tweaked to commands to start without a space only if char before is a non-word character + // https://github.com/ichord/At.js + const atSymbolsWithBar = Object.keys(this.app.controllers).join('|'); + const atSymbolsWithoutBar = Object.keys(this.app.controllers).join(''); + const targetSubtext = subtext.split(/\s+/g).pop(); + const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); - fetchData: function($input, at) { + const accentAChar = decodeURI('%C3%80'); + const accentYChar = decodeURI('%C3%BF'); + + const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi'); + + const match = regexp.exec(targetSubtext); + + if (match) { + return match[1]; + } + return null; + }, + }; + } + + fetchData($input, at) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); - } else if (this.atTypeMap[at] === 'emojis') { + } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases))); } else { - $.getJSON(this.dataSources[this.atTypeMap[at]], (data) => { - this.loadData($input, at, data); - }).fail(() => { this.isLoadingData[at] = false; }); + AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true) + .then((data) => { + this.loadData($input, at, data); + }) + .catch(() => { this.isLoadingData[at] = false; }); } - }, - loadData: function($input, at, data) { + } + + loadData($input, at, data) { this.isLoadingData[at] = false; this.cachedData[at] = data; $input.atwho('load', at, data); // This trigger at.js again // otherwise we would be stuck with loading until the user types return $input.trigger('keyup'); - }, - isLoading(data) { - var dataToInspect = data; + } + + clearCache() { + this.cachedData = {}; + } + + static isLoading(data) { + let dataToInspect = data; if (data && data.length > 0) { dataToInspect = data[0]; } - var loadingState = this.defaultLoadingData[0]; + const loadingState = GfmAutoComplete.defaultLoadingData[0]; return dataToInspect && (dataToInspect === loadingState || dataToInspect.name === loadingState); } +} + +GfmAutoComplete.defaultLoadingData = ['loading']; + +GfmAutoComplete.atTypeMap = { + ':': 'emojis', + '@': 'members', + '#': 'issues', + '!': 'mergeRequests', + '~': 'labels', + '%': 'milestones', + '/': 'commands', +}; + +// Emoji +GfmAutoComplete.Emoji = { + templateFunction(name) { + return `<li> + ${name} ${glEmojiTag(name)} + </li> + `; + }, +}; +// Team Members +GfmAutoComplete.Members = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li>${avatarTag} ${username} <small>${title}</small></li>', +}; +GfmAutoComplete.Labels = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>', +}; +// Issues and MergeRequests +GfmAutoComplete.Issues = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li><small>${id}</small> ${title}</li>', }; +// Milestones +GfmAutoComplete.Milestones = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li>${title}</li>', +}; +GfmAutoComplete.Loading = { + template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>', +}; + +export default GfmAutoComplete; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 6227722b99b..d34561e5512 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,9 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */ /* global fuzzaldrinPlus */ +import { isObject } from './lib/utils/type_utility'; -var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, - bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; +var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote; GitLabDropdownFilter = (function() { var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS; @@ -95,7 +94,7 @@ GitLabDropdownFilter = (function() { // { prop: 'def' } // ] // } - if (gl.utils.isObject(data)) { + if (isObject(data)) { results = {}; for (key in data) { group = data[key]; @@ -213,10 +212,10 @@ GitLabDropdown = (function() { var searchFields, selector, self; this.el = el1; this.options = options; - this.updateLabel = bind(this.updateLabel, this); - this.hidden = bind(this.hidden, this); - this.opened = bind(this.opened, this); - this.shouldPropagate = bind(this.shouldPropagate, this); + this.updateLabel = this.updateLabel.bind(this); + this.hidden = this.hidden.bind(this); + this.opened = this.opened.bind(this); + this.shouldPropagate = this.shouldPropagate.bind(this); self = this; selector = $(this.el).data("target"); this.dropdown = selector != null ? $(selector) : $(this.el).parent(); @@ -398,7 +397,7 @@ GitLabDropdown = (function() { html = [this.noResults()]; } else { // Handle array groups - if (gl.utils.isObject(data)) { + if (isObject(data)) { html = []; for (name in data) { groupData = data[name]; @@ -469,8 +468,8 @@ GitLabDropdown = (function() { // Process the data to make sure rendered data // matches the correct layout - if (this.fullData && hasMultiSelect && this.options.processData) { - const inputValue = this.filterInput.val(); + const inputValue = this.filterInput.val(); + if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) { this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this)); } @@ -632,8 +631,8 @@ GitLabDropdown = (function() { }; GitLabDropdown.prototype.highlightTextMatches = function(text, term) { - var occurrences; - occurrences = fuzzaldrinPlus.match(text, term); + const occurrences = fuzzaldrinPlus.match(text, term); + const indexOf = [].indexOf; return text.split('').map(function(character, i) { if (indexOf.call(occurrences, i) !== -1) { return "<b>" + character + "</b>"; @@ -741,6 +740,12 @@ GitLabDropdown = (function() { $input.attr('id', this.options.inputId); } + if (this.options.multiSelect) { + Object.keys(selectedObject).forEach((attribute) => { + $input.attr(`data-${attribute}`, selectedObject[attribute]); + }); + } + if (this.options.inputMeta) { $input.attr('data-meta', selectedObject[this.options.inputMeta]); } diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index ca3cec07a88..4bef60264bb 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -1,6 +1,6 @@ /* eslint-disable comma-dangle, class-methods-use-this, max-len, space-before-function-paren, arrow-parens, no-param-reassign */ -require('./gl_field_error'); +import './gl_field_error'; const customValidationFlag = 'gl-field-error-ignore'; @@ -31,9 +31,13 @@ class GlFieldErrors { * and prevents disabling of invalid submit button by application.js */ catchInvalidFormSubmit (event) { - if (!event.currentTarget.checkValidity()) { - event.preventDefault(); - event.stopPropagation(); + const $form = $(event.currentTarget); + + if (!$form.attr('novalidate')) { + if (!event.currentTarget.checkValidity()) { + event.preventDefault(); + event.stopPropagation(); + } } } diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index ff06092e4d6..dc9f114af99 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -3,11 +3,14 @@ /* global DropzoneInput */ /* global autosize */ +import GfmAutoComplete from './gfm_auto_complete'; + window.gl = window.gl || {}; -function GLForm(form) { +function GLForm(form, enableGFM = false) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); + this.enableGFM = enableGFM; // Before we start, we should clean up any previous data for this form this.destroy(); // Setup the form @@ -30,8 +33,14 @@ GLForm.prototype.setupForm = function() { this.form.addClass('gfm-form'); // remove notify commit author checkbox for non-commit notes gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion')); - - gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); + new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(this.form.find('.js-gfm-input'), { + emojis: true, + members: this.enableGFM, + issues: this.enableGFM, + milestones: this.enableGFM, + mergeRequests: this.enableGFM, + labels: this.enableGFM, + }); new DropzoneInput(this.form); autosize(this.textarea); } diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js index 521bc77db66..0deb27e522b 100644 --- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js +++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js @@ -2,7 +2,6 @@ import d3 from 'd3'; -const bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; const 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; }; const hasProp = {}.hasOwnProperty; @@ -95,7 +94,7 @@ export const ContributorsMasterGraph = (function(superClass) { function ContributorsMasterGraph(data1) { this.data = data1; - this.update_content = bind(this.update_content, this); + this.update_content = this.update_content.bind(this); this.width = $('.content').width() - 70; this.height = 200; this.x = null; diff --git a/app/assets/javascripts/group_name.js b/app/assets/javascripts/group_name.js index 62675d7e67e..462d792b8d5 100644 --- a/app/assets/javascripts/group_name.js +++ b/app/assets/javascripts/group_name.js @@ -44,18 +44,18 @@ export default class GroupName { showToggle() { this.title.classList.add('wrap'); this.toggle.classList.remove('hidden'); - if (this.isHidden) this.groupTitle.classList.add('is-hidden'); + if (this.isHidden) this.groupTitle.classList.add('hidden'); } hideToggle() { this.title.classList.remove('wrap'); this.toggle.classList.add('hidden'); - if (this.isHidden) this.groupTitle.classList.remove('is-hidden'); + if (this.isHidden) this.groupTitle.classList.remove('hidden'); } toggleGroups() { this.isHidden = !this.isHidden; - this.groupTitle.classList.toggle('is-hidden'); + this.groupTitle.classList.toggle('hidden'); } render() { diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index acfa4bd4c6b..b5975295329 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -3,7 +3,7 @@ prefer-arrow-callback, comma-dangle, consistent-return, yoda, prefer-rest-params, prefer-spread, no-unused-vars, prefer-template, promise/catch-or-return */ -/* global Api */ +import Api from './api'; var slice = [].slice; diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 34e4a257ff9..5b4ca94ed30 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -56,6 +56,8 @@ if (job.import_status === 'finished') { job_item.removeClass("active").addClass("success"); return status_field.html('<span><i class="fa fa-check"></i> done</span>'); + } else if (job.import_status === 'scheduled') { + return status_field.html("<i class='fa fa-spinner fa-spin'></i> scheduled"); } else if (job.import_status === 'started') { return status_field.html("<i class='fa fa-spinner fa-spin'></i> started"); } else { diff --git a/app/assets/javascripts/integrations/index.js b/app/assets/javascripts/integrations/index.js new file mode 100644 index 00000000000..10fe6bac0e8 --- /dev/null +++ b/app/assets/javascripts/integrations/index.js @@ -0,0 +1,7 @@ +/* eslint-disable no-new */ +import IntegrationSettingsForm from './integration_settings_form'; + +$(() => { + const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + integrationSettingsForm.init(); +}); diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js new file mode 100644 index 00000000000..ddd3a6aab99 --- /dev/null +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -0,0 +1,123 @@ +/* global Flash */ + +export default class IntegrationSettingsForm { + constructor(formSelector) { + this.$form = $(formSelector); + + // Form Metadata + this.canTestService = this.$form.data('can-test'); + this.testEndPoint = this.$form.data('test-url'); + + // Form Child Elements + this.$serviceToggle = this.$form.find('#service_active'); + this.$submitBtn = this.$form.find('button[type="submit"]'); + this.$submitBtnLoader = this.$submitBtn.find('.js-btn-spinner'); + this.$submitBtnLabel = this.$submitBtn.find('.js-btn-label'); + } + + init() { + // Initialize View + this.toggleServiceState(this.$serviceToggle.is(':checked')); + + // Bind Event Listeners + this.$serviceToggle.on('change', e => this.handleServiceToggle(e)); + this.$submitBtn.on('click', e => this.handleSettingsSave(e)); + } + + handleSettingsSave(e) { + // Check if Service is marked active, as if not marked active, + // We can skip testing it and directly go ahead to allow form to + // be submitted + if (!this.$serviceToggle.is(':checked')) { + return; + } + + // Service was marked active so now we check; + // 1) If form contents are valid + // 2) If this service can be tested + // If both conditions are true, we override form submission + // and test the service using provided configuration. + if (this.$form.get(0).checkValidity() && this.canTestService) { + e.preventDefault(); + this.testSettings(this.$form.serialize()); + } + } + + handleServiceToggle(e) { + this.toggleServiceState($(e.currentTarget).is(':checked')); + } + + /** + * Change Form's validation enforcement based on service status (active/inactive) + */ + toggleServiceState(serviceActive) { + this.toggleSubmitBtnLabel(serviceActive); + if (serviceActive) { + this.$form.removeAttr('novalidate'); + } else if (!this.$form.attr('novalidate')) { + this.$form.attr('novalidate', 'novalidate'); + } + } + + /** + * Toggle Submit button label based on Integration status and ability to test service + */ + toggleSubmitBtnLabel(serviceActive) { + let btnLabel = 'Save changes'; + + if (serviceActive && this.canTestService) { + btnLabel = 'Test settings and save changes'; + } + + this.$submitBtnLabel.text(btnLabel); + } + + /** + * Toggle Submit button state based on provided boolean value of `saveTestActive` + * When enabled, it does two things, and reverts back when disabled + * + * 1. It shows load spinner on submit button + * 2. Makes submit button disabled + */ + toggleSubmitBtnState(saveTestActive) { + if (saveTestActive) { + this.$submitBtn.disable(); + this.$submitBtnLoader.removeClass('hidden'); + } else { + this.$submitBtn.enable(); + this.$submitBtnLoader.addClass('hidden'); + } + } + + /* eslint-disable promise/catch-or-return, no-new */ + /** + * Test Integration config + */ + testSettings(formData) { + this.toggleSubmitBtnState(true); + $.ajax({ + type: 'PUT', + url: this.testEndPoint, + data: formData, + }) + .done((res) => { + if (res.error) { + new Flash(res.message, null, null, { + title: 'Save anyway', + clickHandler: (e) => { + e.preventDefault(); + this.$form.submit(); + }, + }); + } else { + this.$form.submit(); + } + }) + .fail(() => { + new Flash('Something went wrong on our end.'); + }) + .always(() => { + this.toggleSubmitBtnState(false); + }); + } +} diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js new file mode 100644 index 00000000000..e46c0e90255 --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -0,0 +1,159 @@ +/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */ +/* global IssuableIndex */ +/* global Flash */ + +export default { + init({ container, form, issues, prefixId } = {}) { + this.prefixId = prefixId || 'issue_'; + this.form = form || this.getElement('.bulk-update'); + this.$labelDropdown = this.form.find('.js-label-select'); + this.issues = issues || this.getElement('.issues-list .issue'); + this.willUpdateLabels = false; + this.bindEvents(); + }, + + 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(() => this.onFormSubmitFailure()); + }, + + onFormSubmitFailure() { + this.form.find('[type="submit"]').enable(); + return new Flash("Issue update failed"); + }, + + 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.$labelDropdown.data('indeterminate'); + + 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(), + // For Merge Requests + assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + // For Issues + assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').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) { + formData.update.add_label_ids = this.$labelDropdown.data('marked'); + formData.update.remove_label_ids = this.$labelDropdown.data('unmarked'); + } + return formData; + }, + + setOriginalDropdownData() { + const $labelSelect = $('.bulk-update .js-label-select'); + $labelSelect.data('common', this.getOriginalCommonIds()); + $labelSelect.data('marked', this.getOriginalMarkedIds()); + $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); + }, + + // From issuable's initial bulk selection + getOriginalCommonIds() { + const labelIds = []; + + this.getElement('.selected_issue:checked').each((i, el) => { + labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); + }); + return _.intersection.apply(this, labelIds); + }, + + // From issuable's initial bulk selection + getOriginalMarkedIds() { + const labelIds = []; + this.getElement('.selected_issue:checked').each((i, el) => { + labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); + }); + return _.intersection.apply(this, labelIds); + }, + + // From issuable's initial bulk selection + getOriginalIndeterminateIds() { + const uniqueIds = []; + const labelIds = []; + let issuableLabels = []; + + // Collect unique label IDs for all checked issues + this.getElement('.selected_issue:checked').each((i, el) => { + issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); + issuableLabels.forEach((labelId) => { + // Store unique IDs + if (uniqueIds.indexOf(labelId) === -1) { + uniqueIds.push(labelId); + } + }); + // Store array of IDs per issuable + labelIds.push(issuableLabels); + }); + // Add uniqueIds to add it as argument for _.intersection + labelIds.unshift(uniqueIds); + // Return IDs that are present but not in all selected issueables + return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); + }, + + getElement(selector) { + this.scopeEl = this.scopeEl || $('.content'); + return this.scopeEl.find(selector); + }, +}; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js new file mode 100644 index 00000000000..84bd2e092e6 --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -0,0 +1,165 @@ +/* eslint-disable class-methods-use-this, no-new */ +/* global LabelsSelect */ +/* global MilestoneSelect */ +/* global IssueStatusSelect */ +/* global SubscriptionSelect */ + +import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; + +const HIDDEN_CLASS = 'hidden'; +const DISABLED_CONTENT_CLASS = 'disabled-content'; +const SIDEBAR_EXPANDED_CLASS = 'right-sidebar-expanded issuable-bulk-update-sidebar'; +const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-sidebar'; + +export default class IssuableBulkUpdateSidebar { + constructor() { + this.initDomElements(); + this.bindEvents(); + this.initDropdowns(); + this.setupBulkUpdateActions(); + } + + initDomElements() { + this.$page = $('.page-with-sidebar'); + this.$sidebar = $('.right-sidebar'); + this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide'); + this.$bulkEditSubmitBtn = $('.update-selected-issues'); + this.$bulkUpdateEnableBtn = $('.js-bulk-update-toggle'); + this.$otherFilters = $('.issues-other-filters'); + this.$checkAllContainer = $('.check-all-holder'); + this.$issueChecks = $('.issue-check'); + this.$issuesList = $('.selected_issue'); + this.$issuableIdsInput = $('#update_issuable_ids'); + } + + bindEvents() { + this.$bulkUpdateEnableBtn.on('click', e => this.toggleBulkEdit(e, true)); + this.$bulkEditCancelBtn.on('click', e => this.toggleBulkEdit(e, false)); + this.$checkAllContainer.on('click', e => this.selectAll(e)); + this.$issuesList.on('change', () => this.updateFormState()); + this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit()); + this.$checkAllContainer.on('click', () => this.updateFormState()); + } + + initDropdowns() { + new LabelsSelect(); + new MilestoneSelect(); + new IssueStatusSelect(); + new SubscriptionSelect(); + } + + getNavHeight() { + const navbarHeight = $('.navbar-gitlab').outerHeight(); + const layoutNavHeight = $('.layout-nav').outerHeight(); + const subNavScroll = $('.sub-nav-scroll').outerHeight(); + return navbarHeight + layoutNavHeight + subNavScroll; + } + + initSidebar() { + if (!this.navHeight) { + this.navHeight = this.getNavHeight(); + } + + if (!this.sidebarInitialized) { + $(document).off('scroll').on('scroll', _.throttle(this.setSidebarHeight, 10).bind(this)); + $(window).off('resize').on('resize', _.throttle(this.setSidebarHeight, 10).bind(this)); + this.sidebarInitialized = true; + } + } + + setupBulkUpdateActions() { + IssuableBulkUpdateActions.setOriginalDropdownData(); + } + + updateFormState() { + const noCheckedIssues = !$('.selected_issue:checked').length; + + this.toggleSubmitButtonDisabled(noCheckedIssues); + this.updateSelectedIssuableIds(); + + IssuableBulkUpdateActions.setOriginalDropdownData(); + } + + prepForSubmit() { + // if submit button is disabled, submission is blocked. This ensures we disable after + // form submission is carried out + setTimeout(() => this.$bulkEditSubmitBtn.disable()); + this.updateSelectedIssuableIds(); + } + + toggleBulkEdit(e, enable) { + e.preventDefault(); + + this.toggleSidebarDisplay(enable); + this.toggleBulkEditButtonDisabled(enable); + this.toggleOtherFiltersDisabled(enable); + this.toggleCheckboxDisplay(enable); + + if (enable) { + this.initSidebar(); + } + } + + updateSelectedIssuableIds() { + this.$issuableIdsInput.val(IssuableBulkUpdateSidebar.getCheckedIssueIds()); + } + + selectAll() { + const checkAllButtonState = this.$checkAllContainer.find('input').prop('checked'); + + this.$issuesList.prop('checked', checkAllButtonState); + } + + toggleSidebarDisplay(show) { + this.$page.toggleClass(SIDEBAR_EXPANDED_CLASS, show); + this.$page.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); + this.$sidebar.toggleClass(SIDEBAR_EXPANDED_CLASS, show); + this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); + } + + toggleBulkEditButtonDisabled(disable) { + if (disable) { + this.$bulkUpdateEnableBtn.disable(); + } else { + this.$bulkUpdateEnableBtn.enable(); + } + } + + toggleCheckboxDisplay(show) { + this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show); + this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); + } + + toggleOtherFiltersDisabled(disable) { + this.$otherFilters.toggleClass(DISABLED_CONTENT_CLASS, disable); + } + + toggleSubmitButtonDisabled(disable) { + if (disable) { + this.$bulkEditSubmitBtn.disable(); + } else { + this.$bulkEditSubmitBtn.enable(); + } + } + // loosely based on method of the same name in right_sidebar.js + setSidebarHeight() { + const currentScrollDepth = window.pageYOffset || 0; + const diff = this.navHeight - currentScrollDepth; + + if (diff > 0) { + this.$sidebar.outerHeight(window.innerHeight - diff); + } else { + this.$sidebar.outerHeight('100%'); + } + } + + static getCheckedIssueIds() { + const $checkedIssues = $('.selected_issue:checked'); + + if ($checkedIssues.length > 0) { + return $.map($checkedIssues, value => $(value).data('id')); + } + + return []; + } +} diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 834b98e8601..a4d7bf096ef 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -1,8 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-new, comma-dangle, quotes, prefer-arrow-callback, consistent-return, one-var, no-var, one-var-declaration-per-line, no-underscore-dangle, max-len */ -/* global UsersSelect */ /* global bp */ import Cookies from 'js-cookie'; +import UsersSelect from './users_select'; (function() { this.IssuableContext = (function() { @@ -47,7 +47,6 @@ import Cookies from 'js-cookie'; Cookies.set('collapsed_gutter', true); } }); - $(".right-sidebar").niceScroll(); } IssuableContext.prototype.initParticipants = function() { diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 687c2bb6110..92f6f0d4117 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -1,14 +1,14 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, quotes, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */ /* global GitLab */ -/* global UsersSelect */ /* global ZenMode */ /* global Autosave */ /* global dateFormat */ /* global Pikaday */ -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; +import UsersSelect from './users_select'; +import GfmAutoComplete from './gfm_auto_complete'; +(function() { this.IssuableForm = (function() { IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?'; @@ -17,11 +17,11 @@ function IssuableForm(form) { var $issuableDueDate, calendar; this.form = form; - this.toggleWip = bind(this.toggleWip, this); - this.renderWipExplanation = bind(this.renderWipExplanation, this); - this.resetAutosave = bind(this.resetAutosave, this); - this.handleSubmit = bind(this.handleSubmit, this); - gl.GfmAutoComplete.setup(); + this.toggleWip = this.toggleWip.bind(this); + this.renderWipExplanation = this.renderWipExplanation.bind(this); + this.resetAutosave = this.resetAutosave.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(); new UsersSelect(); new ZenMode(); this.titleField = this.form.find("input[name*='[title]']"); diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable_index.js index 3bfce32768a..5c96646def8 100644 --- a/app/assets/javascripts/issuable.js +++ b/app/assets/javascripts/issuable_index.js @@ -1,30 +1,33 @@ /* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */ -/* global Issuable */ +/* global IssuableIndex */ + +import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; +import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; ((global) => { var issuable_created; issuable_created = false; - global.Issuable = { - init: function() { - Issuable.initTemplates(); - Issuable.initSearch(); - Issuable.initChecks(); - Issuable.initResetFilters(); - Issuable.resetIncomingEmailToken(); - return Issuable.initLabelFilterRemove(); + global.IssuableIndex = { + init: function(pagePrefix) { + IssuableIndex.initTemplates(); + IssuableIndex.initSearch(); + IssuableIndex.initBulkUpdate(pagePrefix); + IssuableIndex.initResetFilters(); + IssuableIndex.resetIncomingEmailToken(); + IssuableIndex.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> <% }); %>'); + return IssuableIndex.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() { const $searchInput = $('#issuable_search'); - Issuable.initSearchState($searchInput); + IssuableIndex.initSearchState($searchInput); // `immediate` param set to false debounces on the `trailing` edge, lets user finish typing - const debouncedExecSearch = _.debounce(Issuable.executeSearch, 1000, false); + const debouncedExecSearch = _.debounce(IssuableIndex.executeSearch, 1000, false); $searchInput.off('keyup').on('keyup', debouncedExecSearch); @@ -37,16 +40,16 @@ initSearchState: function($searchInput) { const currentSearchVal = $searchInput.val(); - Issuable.searchState = { + IssuableIndex.searchState = { elem: $searchInput, current: currentSearchVal }; - Issuable.maybeFocusOnSearch(); + IssuableIndex.maybeFocusOnSearch(); }, accessSearchPristine: function(set) { // store reference to previous value to prevent search on non-mutating keyup - const state = Issuable.searchState; + const state = IssuableIndex.searchState; const currentSearchVal = state.elem.val(); if (set) { @@ -56,10 +59,10 @@ } }, maybeFocusOnSearch: function() { - const currentSearchVal = Issuable.searchState.current; + const currentSearchVal = IssuableIndex.searchState.current; if (currentSearchVal && currentSearchVal !== '') { const queryLength = currentSearchVal.length; - const $searchInput = Issuable.searchState.elem; + const $searchInput = IssuableIndex.searchState.elem; /* The following ensures that the cursor is initially placed at * the end of search input when focus is applied. It accounts @@ -80,7 +83,7 @@ const $searchValue = $search.val(); const $filtersForm = $('.js-filter-form'); const $input = $(`input[name='${$searchName}']`, $filtersForm); - const isPristine = Issuable.accessSearchPristine(); + const isPristine = IssuableIndex.accessSearchPristine(); if (isPristine) { return; @@ -92,7 +95,7 @@ $input.val($searchValue); } - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); }, initLabelFilterRemove: function() { return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) { @@ -103,7 +106,7 @@ return this.value === $button.data('label'); }).remove(); // Submit the form to get new data - Issuable.filterResults($('.filter-form')); + IssuableIndex.filterResults($('.filter-form')); }); }, filterResults: (function(_this) { @@ -132,38 +135,18 @@ gl.utils.visitUrl(baseIssuesUrl); }); }, - initChecks: function() { - this.issuableBulkActions = $('.bulk-update').data('bulkActions'); - $('.check_all_issues').off('click').on('click', function() { - $('.selected_issue').prop('checked', this.checked); - return Issuable.checkChanged(); - }); - return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this)); - }, - checkChanged: function() { - const $checkedIssues = $('.selected_issue:checked'); - const $updateIssuesIds = $('#update_issuable_ids'); - const $issuesOtherFilters = $('.issues-other-filters'); - const $issuesBulkUpdate = $('.issues_bulk_update'); - - this.issuableBulkActions.willUpdateLabels = false; - this.issuableBulkActions.setOriginalDropdownData(); - - if ($checkedIssues.length > 0) { - const ids = $.map($checkedIssues, function(value) { - return $(value).data('id'); + initBulkUpdate: function(pagePrefix) { + const userCanBulkUpdate = $('.issues-bulk-update').length > 0; + const alreadyInitialized = !!this.bulkUpdateSidebar; + + if (userCanBulkUpdate && !alreadyInitialized) { + IssuableBulkUpdateActions.init({ + prefixId: pagePrefix, }); - $updateIssuesIds.val(ids); - $issuesOtherFilters.hide(); - $issuesBulkUpdate.show(); - } else { - $updateIssuesIds.val([]); - $issuesBulkUpdate.hide(); - $issuesOtherFilters.show(); + + this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); } - return true; }, - resetIncomingEmailToken: function() { $('.incoming-email-token-reset').on('click', function(e) { e.preventDefault(); diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 694c6177a07..0860e237ce1 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,11 +1,11 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */ - /* global Flash */ -import CreateMergeRequestDropdown from './create_merge_request_dropdown'; +/* global Flash */ -require('./flash'); -require('~/lib/utils/text_utility'); -require('vendor/jquery.waitforimages'); -require('./task_list'); +import 'vendor/jquery.waitforimages'; +import '~/lib/utils/text_utility'; +import './flash'; +import './task_list'; +import CreateMergeRequestDropdown from './create_merge_request_dropdown'; class Issue { constructor() { diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js deleted file mode 100644 index 0740a9f559c..00000000000 --- a/app/assets/javascripts/issue_show/actions/tasks.js +++ /dev/null @@ -1,27 +0,0 @@ -export default (newStateData, tasks) => { - const $tasks = $('#task_status'); - const $tasksShort = $('#task_status_short'); - const $issueableHeader = $('.issuable-header'); - const tasksStates = { newState: null, currentState: null }; - - if ($tasks.length === 0) { - if (!(newStateData.task_status.indexOf('0 of 0') === 0)) { - $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); - } else { - $issueableHeader.append('<span id="task_status"></span>'); - } - } else { - tasksStates.newState = newStateData.task_status.indexOf('0 of 0') === 0; - tasksStates.currentState = tasks.indexOf('0 of 0') === 0; - } - - if ($tasks.length !== 0 && !tasksStates.newState) { - $tasks.text(newStateData.task_status); - $tasksShort.text(newStateData.task_status); - } else if (tasksStates.currentState) { - $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); - } else if (tasksStates.newState) { - $tasks.remove(); - $tasksShort.remove(); - } -}; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue new file mode 100644 index 00000000000..e14414d3f68 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -0,0 +1,273 @@ +<script> +/* global Flash */ +import Visibility from 'visibilityjs'; +import Poll from '../../lib/utils/poll'; +import eventHub from '../event_hub'; +import Service from '../services/index'; +import Store from '../stores'; +import titleComponent from './title.vue'; +import descriptionComponent from './description.vue'; +import editedComponent from './edited.vue'; +import formComponent from './form.vue'; +import '../../lib/utils/url_utility'; + +export default { + props: { + endpoint: { + required: true, + type: String, + }, + canMove: { + required: true, + type: Boolean, + }, + canUpdate: { + required: true, + type: Boolean, + }, + canDestroy: { + required: true, + type: Boolean, + }, + issuableRef: { + type: String, + required: true, + }, + initialTitleHtml: { + type: String, + required: true, + }, + initialTitleText: { + type: String, + required: true, + }, + initialDescriptionHtml: { + type: String, + required: false, + default: '', + }, + initialDescriptionText: { + type: String, + required: false, + default: '', + }, + updatedAt: { + type: String, + required: false, + default: '', + }, + updatedByName: { + type: String, + required: false, + default: '', + }, + updatedByPath: { + type: String, + required: false, + default: '', + }, + issuableTemplates: { + type: Array, + required: false, + default: () => [], + }, + isConfidential: { + type: Boolean, + required: true, + }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + projectsAutocompleteUrl: { + type: String, + required: true, + }, + }, + data() { + const store = new Store({ + titleHtml: this.initialTitleHtml, + titleText: this.initialTitleText, + descriptionHtml: this.initialDescriptionHtml, + descriptionText: this.initialDescriptionText, + updatedAt: this.updatedAt, + updatedByName: this.updatedByName, + updatedByPath: this.updatedByPath, + }); + + return { + store, + state: store.state, + showForm: false, + }; + }, + computed: { + formState() { + return this.store.formState; + }, + hasUpdated() { + return !!this.state.updatedAt; + }, + }, + components: { + descriptionComponent, + titleComponent, + editedComponent, + formComponent, + }, + methods: { + openForm() { + if (!this.showForm) { + this.showForm = true; + this.store.setFormState({ + title: this.state.titleText, + confidential: this.isConfidential, + description: this.state.descriptionText, + lockedWarningVisible: false, + move_to_project_id: 0, + updateLoading: false, + }); + } + }, + closeForm() { + this.showForm = false; + }, + updateIssuable() { + const canPostUpdate = this.store.formState.move_to_project_id !== 0 ? + confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert + + if (!canPostUpdate) { + this.store.setFormState({ + updateLoading: false, + }); + return; + } + + this.service.updateIssuable(this.store.formState) + .then(res => res.json()) + .then((data) => { + if (location.pathname !== data.web_url) { + gl.utils.visitUrl(data.web_url); + } else if (data.confidential !== this.isConfidential) { + gl.utils.visitUrl(location.pathname); + } + + return this.service.getData(); + }) + .then(res => res.json()) + .then((data) => { + this.store.updateState(data); + eventHub.$emit('close.form'); + }) + .catch(() => { + eventHub.$emit('close.form'); + return new Flash('Error updating issue'); + }); + }, + deleteIssuable() { + this.service.deleteIssuable() + .then(res => res.json()) + .then((data) => { + // Stop the poll so we don't get 404's with the issue not existing + this.poll.stop(); + + gl.utils.visitUrl(data.web_url); + }) + .catch(() => { + eventHub.$emit('close.form'); + return new Flash('Error deleting issue'); + }); + }, + }, + created() { + this.service = new Service(this.endpoint); + this.poll = new Poll({ + resource: this.service, + method: 'getData', + successCallback: (res) => { + const data = res.json(); + const shouldUpdate = this.store.stateShouldUpdate(data); + + this.store.updateState(data); + + if (this.showForm && (shouldUpdate.title || shouldUpdate.description)) { + this.store.formState.lockedWarningVisible = true; + } + }, + errorCallback(err) { + throw new Error(err); + }, + }); + + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + + eventHub.$on('delete.issuable', this.deleteIssuable); + eventHub.$on('update.issuable', this.updateIssuable); + eventHub.$on('close.form', this.closeForm); + eventHub.$on('open.form', this.openForm); + }, + beforeDestroy() { + eventHub.$off('delete.issuable', this.deleteIssuable); + eventHub.$off('update.issuable', this.updateIssuable); + eventHub.$off('close.form', this.closeForm); + eventHub.$off('open.form', this.openForm); + }, +}; +</script> + +<template> + <div> + <form-component + v-if="canUpdate && showForm" + :form-state="formState" + :can-move="canMove" + :can-destroy="canDestroy" + :issuable-templates="issuableTemplates" + :markdown-docs="markdownDocs" + :markdown-preview-url="markdownPreviewUrl" + :project-path="projectPath" + :project-namespace="projectNamespace" + :projects-autocomplete-url="projectsAutocompleteUrl" + /> + <div v-else> + <title-component + :issuable-ref="issuableRef" + :title-html="state.titleHtml" + :title-text="state.titleText" /> + <description-component + v-if="state.descriptionHtml" + :can-update="canUpdate" + :description-html="state.descriptionHtml" + :description-text="state.descriptionText" + :updated-at="state.updatedAt" + :task-status="state.taskStatus" /> + <edited-component + v-if="hasUpdated" + :updated-at="state.updatedAt" + :updated-by-name="state.updatedByName" + :updated-by-path="state.updatedByPath" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue new file mode 100644 index 00000000000..5ae617356e0 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -0,0 +1,96 @@ +<script> + import animateMixin from '../mixins/animate'; + + export default { + mixins: [animateMixin], + props: { + canUpdate: { + type: Boolean, + required: true, + }, + descriptionHtml: { + type: String, + required: true, + }, + descriptionText: { + type: String, + required: true, + }, + taskStatus: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + preAnimation: false, + pulseAnimation: false, + }; + }, + watch: { + descriptionHtml() { + this.animateChange(); + + this.$nextTick(() => { + this.renderGFM(); + }); + }, + taskStatus() { + const taskRegexMatches = this.taskStatus.match(/(\d+) of (\d+)/); + const $issuableHeader = $('.issuable-meta'); + const $tasks = $('#task_status', $issuableHeader); + const $tasksShort = $('#task_status_short', $issuableHeader); + + if (taskRegexMatches) { + $tasks.text(this.taskStatus); + $tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`); + } else { + $tasks.text(''); + $tasksShort.text(''); + } + }, + }, + methods: { + renderGFM() { + $(this.$refs['gfm-entry-content']).renderGFM(); + + if (this.canUpdate) { + // eslint-disable-next-line no-new + new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }); + } + }, + }, + mounted() { + this.renderGFM(); + }, + }; +</script> + +<template> + <div + v-if="descriptionHtml" + class="description" + :class="{ + 'js-task-list-container': canUpdate + }"> + <div + class="wiki" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="descriptionHtml" + ref="gfm-content"> + </div> + <textarea + class="hidden js-task-list-field" + v-if="descriptionText" + v-model="descriptionText"> + </textarea> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue new file mode 100644 index 00000000000..8c81575fe6f --- /dev/null +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -0,0 +1,79 @@ +<script> + import updateMixin from '../mixins/update'; + import eventHub from '../event_hub'; + + export default { + mixins: [updateMixin], + props: { + canDestroy: { + type: Boolean, + required: true, + }, + formState: { + type: Object, + required: true, + }, + }, + data() { + return { + deleteLoading: false, + }; + }, + computed: { + isSubmitEnabled() { + return this.formState.title.trim() !== ''; + }, + }, + methods: { + closeForm() { + eventHub.$emit('close.form'); + }, + deleteIssuable() { + // eslint-disable-next-line no-alert + if (confirm('Issue will be removed! Are you sure?')) { + this.deleteLoading = true; + + eventHub.$emit('delete.issuable'); + } + }, + }, + }; +</script> + +<template> + <div class="prepend-top-default append-bottom-default clearfix"> + <button + class="btn btn-save pull-left" + :class="{ disabled: formState.updateLoading || !isSubmitEnabled }" + type="submit" + :disabled="formState.updateLoading || !isSubmitEnabled" + @click.prevent="updateIssuable"> + Save changes + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" + v-if="formState.updateLoading"> + </i> + </button> + <button + class="btn btn-default pull-right" + type="button" + @click="closeForm"> + Cancel + </button> + <button + v-if="canDestroy" + class="btn btn-danger pull-right append-right-default" + :class="{ disabled: deleteLoading }" + type="button" + :disabled="deleteLoading" + @click="deleteIssuable"> + Delete + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" + v-if="deleteLoading"> + </i> + </button> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue index f5038e55c09..fc34da9ffd9 100644 --- a/app/assets/javascripts/issue_show/components/edited.vue +++ b/app/assets/javascripts/issue_show/components/edited.vue @@ -53,3 +53,7 @@ export default { </span> </small> </template> +<<<<<<< HEAD +======= + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 diff --git a/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue new file mode 100644 index 00000000000..a0ff08e9111 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue @@ -0,0 +1,23 @@ +<script> + export default { + props: { + formState: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <fieldset class="checkbox"> + <label for="issue-confidential"> + <input + type="checkbox" + value="1" + id="issue-confidential" + v-model="formState.confidential" /> + This issue is confidential and should only be visible to team members with at least Reporter access. + </label> + </fieldset> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue new file mode 100644 index 00000000000..30a1be5cb50 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -0,0 +1,54 @@ +<script> + /* global Flash */ + import updateMixin from '../../mixins/update'; + import markdownField from '../../../vue_shared/components/markdown/field.vue'; + + export default { + mixins: [updateMixin], + props: { + formState: { + type: Object, + required: true, + }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, + }, + components: { + markdownField, + }, + mounted() { + this.$refs.textarea.focus(); + }, + }; +</script> + +<template> + <div class="common-note-form"> + <label + class="sr-only" + for="issue-description"> + Description + </label> + <markdown-field + :markdown-preview-url="markdownPreviewUrl" + :markdown-docs="markdownDocs"> + <textarea + id="issue-description" + class="note-textarea js-gfm-input js-autosize markdown-area" + data-supports-slash-commands="false" + aria-label="Description" + v-model="formState.description" + ref="textarea" + slot="textarea" + placeholder="Write a comment or drag your files here..." + @keydown.meta.enter="updateIssuable"> + </textarea> + </markdown-field> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue new file mode 100644 index 00000000000..1c40b286513 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -0,0 +1,111 @@ +<script> + export default { + props: { + formState: { + type: Object, + required: true, + }, + issuableTemplates: { + type: Array, + required: false, + default: () => [], + }, + projectPath: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + }, + computed: { + issuableTemplatesJson() { + return JSON.stringify(this.issuableTemplates); + }, + }, + mounted() { + // Create the editor for the template + const editor = document.querySelector('.detail-page-description .note-textarea') || {}; + editor.setValue = (val) => { + this.formState.description = val; + }; + editor.getValue = () => this.formState.description; + + this.issuableTemplate = new gl.IssuableTemplateSelectors({ + $dropdowns: $(this.$refs.toggle), + editor, + }); + }, + }; +</script> + +<template> + <div + class="dropdown js-issuable-selector-wrap" + data-issuable-type="issue"> + <button + class="dropdown-menu-toggle js-issuable-selector" + type="button" + ref="toggle" + data-field-name="issuable_template" + data-selected="null" + data-toggle="dropdown" + :data-namespace-path="projectNamespace" + :data-project-path="projectPath" + :data-data="issuableTemplatesJson"> + <span class="dropdown-toggle-text"> + Choose a template + </span> + <i + aria-hidden="true" + class="fa fa-chevron-down"> + </i> + </button> + <div class="dropdown-menu dropdown-select"> + <div class="dropdown-title"> + Choose a template + <button + class="dropdown-title-button dropdown-menu-close" + aria-label="Close" + type="button"> + <i + aria-hidden="true" + class="fa fa-times dropdown-menu-close-icon"> + </i> + </button> + </div> + <div class="dropdown-input"> + <input + type="search" + class="dropdown-input-field" + placeholder="Filter" + autocomplete="off" /> + <i + aria-hidden="true" + class="fa fa-search dropdown-input-search"> + </i> + <i + role="button" + aria-label="Clear templates search input" + class="fa fa-times dropdown-input-clear js-dropdown-input-clear"> + </i> + </div> + <div class="dropdown-content"></div> + <div class="dropdown-footer"> + <ul class="dropdown-footer-list"> + <li> + <a class="no-template"> + No template + </a> + </li> + <li> + <a class="reset-template"> + Reset template + </a> + </li> + </ul> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue new file mode 100644 index 00000000000..f811fb0de24 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue @@ -0,0 +1,83 @@ +<script> + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + formState: { + type: Object, + required: true, + }, + projectsAutocompleteUrl: { + type: String, + required: true, + }, + }, + mounted() { + const $moveDropdown = $(this.$refs['move-dropdown']); + + $moveDropdown.select2({ + ajax: { + url: this.projectsAutocompleteUrl, + quietMillis: 125, + data(term, page, context) { + return { + search: term, + offset_id: context, + }; + }, + results(data) { + const more = data.length >= 50; + const context = data[data.length - 1] ? data[data.length - 1].id : null; + + return { + results: data, + more, + context, + }; + }, + }, + formatResult(project) { + return project.name_with_namespace; + }, + formatSelection(project) { + return project.name_with_namespace; + }, + }) + .on('change', (e) => { + this.formState.move_to_project_id = parseInt(e.target.value, 10); + }); + }, + beforeDestroy() { + $(this.$refs['move-dropdown']).select2('destroy'); + }, + }; +</script> + +<template> + <fieldset> + <label + for="issuable-move" + class="sr-only"> + Move + </label> + <div class="issuable-form-select-holder append-right-5"> + <input + ref="move-dropdown" + type="hidden" + id="issuable-move" + data-placeholder="Move to a different project" /> + </div> + <span + data-placement="auto top" + 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." + ref="tooltip"> + <i + class="fa fa-question-circle" + aria-hidden="true"> + </i> + </span> + </fieldset> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue new file mode 100644 index 00000000000..6556bf117e2 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/title.vue @@ -0,0 +1,31 @@ +<script> + import updateMixin from '../../mixins/update'; + + export default { + mixins: [updateMixin], + props: { + formState: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <fieldset> + <label + class="sr-only" + for="issue-title"> + Title + </label> + <input + id="issue-title" + class="form-control" + type="text" + placeholder="Issue title" + aria-label="Issue title" + v-model="formState.title" + @keydown.meta.enter="updateIssuable" /> + </fieldset> +</template> diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue new file mode 100644 index 00000000000..76ec3dc9a5d --- /dev/null +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -0,0 +1,104 @@ +<script> + import lockedWarning from './locked_warning.vue'; + import titleField from './fields/title.vue'; + import descriptionField from './fields/description.vue'; + import editActions from './edit_actions.vue'; + import descriptionTemplate from './fields/description_template.vue'; + import projectMove from './fields/project_move.vue'; + import confidentialCheckbox from './fields/confidential_checkbox.vue'; + + export default { + props: { + canMove: { + type: Boolean, + required: true, + }, + canDestroy: { + type: Boolean, + required: true, + }, + formState: { + type: Object, + required: true, + }, + issuableTemplates: { + type: Array, + required: false, + default: () => [], + }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + projectsAutocompleteUrl: { + type: String, + required: true, + }, + }, + components: { + lockedWarning, + titleField, + descriptionField, + descriptionTemplate, + editActions, + projectMove, + confidentialCheckbox, + }, + computed: { + hasIssuableTemplates() { + return this.issuableTemplates.length; + }, + }, + }; +</script> + +<template> + <form> + <locked-warning v-if="formState.lockedWarningVisible" /> + <div class="row"> + <div + class="col-sm-4 col-lg-3" + v-if="hasIssuableTemplates"> + <description-template + :form-state="formState" + :issuable-templates="issuableTemplates" + :project-path="projectPath" + :project-namespace="projectNamespace" /> + </div> + <div + :class="{ + 'col-sm-8 col-lg-9': hasIssuableTemplates, + 'col-xs-12': !hasIssuableTemplates, + }"> + <title-field + :form-state="formState" + :issuable-templates="issuableTemplates" /> + </div> + </div> + <description-field + :form-state="formState" + :markdown-preview-url="markdownPreviewUrl" + :markdown-docs="markdownDocs" /> + <confidential-checkbox + :form-state="formState" /> + <project-move + v-if="canMove" + :form-state="formState" + :projects-autocomplete-url="projectsAutocompleteUrl" /> + <edit-actions + :form-state="formState" + :can-destroy="canDestroy" /> + </form> +</template> diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue new file mode 100644 index 00000000000..1c2789f154a --- /dev/null +++ b/app/assets/javascripts/issue_show/components/locked_warning.vue @@ -0,0 +1,20 @@ +<script> + export default { + computed: { + currentPath() { + return location.pathname; + }, + }, + }; +</script> + +<template> + <div class="alert alert-danger"> + Someone edited the issue at the same time you did. Please check out + <a + :href="currentPath" + target="_blank" + rel="nofollow">the issue</a> + and make sure your changes will not unintentionally remove theirs. + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue new file mode 100644 index 00000000000..a9dabd4cff1 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -0,0 +1,53 @@ +<script> + import animateMixin from '../mixins/animate'; + + export default { + mixins: [animateMixin], + data() { + return { + preAnimation: false, + pulseAnimation: false, + titleEl: document.querySelector('title'), + }; + }, + props: { + issuableRef: { + type: String, + required: true, + }, + titleHtml: { + type: String, + required: true, + }, + titleText: { + type: String, + required: true, + }, + }, + watch: { + titleHtml() { + this.setPageTitle(); + this.animateChange(); + }, + }, + methods: { + setPageTitle() { + const currentPageTitleScope = this.titleEl.innerText.split('·'); + currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; + this.titleEl.textContent = currentPageTitleScope.join('·'); + }, + }, + }; +</script> + +<template> + <h2 + class="title" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="titleHtml" + > + </h2> +</template> diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/issue_show/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 3401fc8d83b..be18a5983be 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,7 +1,9 @@ import Vue from 'vue'; -import IssueTitle from './issue_title_description.vue'; +import eventHub from './event_hub'; +import issuableApp from './components/app.vue'; import '../vue_shared/vue_resource_interceptor'; +<<<<<<< HEAD (() => { const issueTitleData = document.querySelector('.issue-title-data').dataset; const initialTitle = document.querySelector('.js-issue-title').innerHTML; @@ -19,7 +21,52 @@ import '../vue_shared/vue_resource_interceptor'; initialDescription: initialDescription ? initialDescription.innerHTML : '', }, }), +======= +document.addEventListener('DOMContentLoaded', () => { + const initialDataEl = document.getElementById('js-issuable-app-initial-data'); + const initialData = JSON.parse(initialDataEl.innerHTML.replace(/"/g, '"')); + + $('.issuable-edit').on('click', (e) => { + e.preventDefault(); + + eventHub.$emit('open.form'); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 }); - return vm; -})(); + return new Vue({ + el: document.getElementById('js-issuable-app'), + components: { + issuableApp, + }, + data() { + return { + ...initialData, + }; + }, + render(createElement) { + return createElement('issuable-app', { + props: { + canUpdate: this.canUpdate, + canDestroy: this.canDestroy, + canMove: this.canMove, + endpoint: this.endpoint, + issuableRef: this.issuableRef, + initialTitleHtml: this.initialTitleHtml, + initialTitleText: this.initialTitleText, + initialDescriptionHtml: this.initialDescriptionHtml, + initialDescriptionText: this.initialDescriptionText, + issuableTemplates: this.issuableTemplates, + isConfidential: this.isConfidential, + markdownPreviewUrl: this.markdownPreviewUrl, + markdownDocs: this.markdownDocs, + projectPath: this.projectPath, + projectNamespace: this.projectNamespace, + projectsAutocompleteUrl: this.projectsAutocompleteUrl, + updatedAt: this.updatedAt, + updatedByName: this.updatedByName, + updatedByPath: this.updatedByPath, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js new file mode 100644 index 00000000000..4816393da1f --- /dev/null +++ b/app/assets/javascripts/issue_show/mixins/animate.js @@ -0,0 +1,13 @@ +export default { + methods: { + animateChange() { + this.preAnimation = true; + this.pulseAnimation = false; + + setTimeout(() => { + this.preAnimation = false; + this.pulseAnimation = true; + }); + }, + }, +}; diff --git a/app/assets/javascripts/issue_show/mixins/update.js b/app/assets/javascripts/issue_show/mixins/update.js new file mode 100644 index 00000000000..72be65b426f --- /dev/null +++ b/app/assets/javascripts/issue_show/mixins/update.js @@ -0,0 +1,10 @@ +import eventHub from '../event_hub'; + +export default { + methods: { + updateIssuable() { + this.formState.updateLoading = true; + eventHub.$emit('update.issuable'); + }, + }, +}; diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js index c4ab0b1e07a..6f0fd0b1768 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issue_show/services/index.js @@ -1,10 +1,29 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + export default class Service { - constructor(resource, endpoint) { - this.resource = resource; + constructor(endpoint) { this.endpoint = endpoint; + + this.resource = Vue.resource(`${this.endpoint}.json`, {}, { + realtimeChanges: { + method: 'GET', + url: `${this.endpoint}/realtime_changes`, + }, + }); + } + + getData() { + return this.resource.realtimeChanges(); + } + + deleteIssuable() { + return this.resource.delete(); } - getTitle() { - return this.resource.get(this.endpoint); + updateIssuable(data) { + return this.resource.update(data); } } diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js new file mode 100644 index 00000000000..27c2d349f52 --- /dev/null +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -0,0 +1,52 @@ +export default class Store { + constructor({ + titleHtml, + titleText, + descriptionHtml, + descriptionText, + updatedAt, + updatedByName, + updatedByPath, + }) { + this.state = { + titleHtml, + titleText, + descriptionHtml, + descriptionText, + taskStatus: '', + updatedAt, + updatedByName, + updatedByPath, + }; + this.formState = { + title: '', + confidential: false, + description: '', + lockedWarningVisible: false, + move_to_project_id: 0, + updateLoading: false, + }; + } + + updateState(data) { + this.state.titleHtml = data.title; + this.state.titleText = data.title_text; + this.state.descriptionHtml = data.description; + this.state.descriptionText = data.description_text; + this.state.taskStatus = data.task_status; + this.state.updatedAt = data.updated_at; + this.state.updatedByName = data.updated_by_name; + this.state.updatedByPath = data.updated_by_path; + } + + stateShouldUpdate(data) { + return { + title: this.state.titleText !== data.title_text, + description: this.state.descriptionText !== data.description_text, + }; + } + + setFormState(state) { + this.formState = Object.assign(this.formState, state); + } +} diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js deleted file mode 100644 index fee3429e2b8..00000000000 --- a/app/assets/javascripts/issues_bulk_assignment.js +++ /dev/null @@ -1,166 +0,0 @@ -/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */ -/* global Issuable */ -/* global Flash */ - -((global) => { - class IssuableBulkActions { - constructor({ container, form, issues, prefixId } = {}) { - this.prefixId = prefixId || 'issue_'; - this.form = form || this.getElement('.bulk-update'); - this.$labelDropdown = this.form.find('.js-label-select'); - 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(); - } - - 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.$labelDropdown.data('indeterminate'); - - 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(), - // For Merge Requests - assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), - // For Issues - assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').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) { - formData.update.add_label_ids = this.$labelDropdown.data('marked'); - formData.update.remove_label_ids = this.$labelDropdown.data('unmarked'); - } - return formData; - } - - setOriginalDropdownData() { - const $labelSelect = $('.bulk-update .js-label-select'); - $labelSelect.data('common', this.getOriginalCommonIds()); - $labelSelect.data('marked', this.getOriginalMarkedIds()); - $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); - } - - // From issuable's initial bulk selection - getOriginalCommonIds() { - const labelIds = []; - - this.getElement('.selected_issue:checked').each((i, el) => { - labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); - }); - return _.intersection.apply(this, labelIds); - } - - // From issuable's initial bulk selection - getOriginalMarkedIds() { - const labelIds = []; - this.getElement('.selected_issue:checked').each((i, el) => { - labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); - }); - return _.intersection.apply(this, labelIds); - } - - // From issuable's initial bulk selection - getOriginalIndeterminateIds() { - const uniqueIds = []; - const labelIds = []; - let issuableLabels = []; - - // Collect unique label IDs for all checked issues - this.getElement('.selected_issue:checked').each((i, el) => { - issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); - issuableLabels.forEach((labelId) => { - // Store unique IDs - if (uniqueIds.indexOf(labelId) === -1) { - uniqueIds.push(labelId); - } - }); - // Store array of IDs per issuable - labelIds.push(issuableLabels); - }); - // Add uniqueIds to add it as argument for _.intersection - labelIds.unshift(uniqueIds); - // Return IDs that are present but not in all selected issueables - return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); - } - - getElement(selector) { - this.scopeEl = this.scopeEl || $('.content'); - return this.scopeEl.find(selector); - } - } - - global.IssuableBulkActions = IssuableBulkActions; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js index 17a3fc1b1e4..03dd61b4263 100644 --- a/app/assets/javascripts/labels.js +++ b/app/assets/javascripts/labels.js @@ -1,11 +1,9 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, vars-on-top, no-unused-vars, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Labels = (function() { function Labels() { - this.setSuggestedColor = bind(this.setSuggestedColor, this); - this.updateColorPreview = bind(this.updateColorPreview, this); + this.setSuggestedColor = this.setSuggestedColor.bind(this); + this.updateColorPreview = this.updateColorPreview.bind(this); var form; form = $('.label-form'); this.cleanBinding(); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index ac5ce84e31b..8d7d3d73571 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -2,6 +2,8 @@ /* global Issuable */ /* global ListLabel */ +import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; + (function() { this.LabelsSelect = (function() { function LabelsSelect(els) { @@ -430,20 +432,15 @@ if ($('.selected_issue:checked').length) { return; } - return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label'); + return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label'); }; LabelsSelect.prototype.enableBulkLabelDropdown = function() { - var issuableBulkActions; - if ($('.selected_issue:checked').length) { - issuableBulkActions = $('.bulk-update').data('bulkActions'); - return issuableBulkActions.willUpdateLabels = true; - } + IssuableBulkUpdateActions.willUpdateLabels = true; }; LabelsSelect.prototype.setDropdownData = function($dropdown, isMarking, value) { var i, markedIds, unmarkedIds, indeterminateIds; - var issuableBulkActions = $('.bulk-update').data('bulkActions'); markedIds = $dropdown.data('marked') || []; unmarkedIds = $dropdown.data('unmarked') || []; @@ -469,13 +466,13 @@ } // If an indeterminate item is being unmarked - if (issuableBulkActions.getOriginalIndeterminateIds().indexOf(value) > -1) { + if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) { unmarkedIds.push(value); } // If a marked item is being unmarked // (a marked item could also be a label that is present in all selection) - if (issuableBulkActions.getOriginalCommonIds().indexOf(value) > -1) { + if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) { unmarkedIds.push(value); } } diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index a5f99bcdd8f..71064ccc539 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, no-unused-vars, one-var, one-var-declaration-per-line, vars-on-top, max-len */ +import _ from 'underscore'; (function() { var hideEndFade; @@ -45,4 +46,13 @@ } }); }); + + function applyScrollNavClass() { + const scrollOpacityHeight = 40; + $('.navbar-border').css('opacity', Math.min($(window).scrollTop() / scrollOpacityHeight, 1)); + } + + $(() => { + $(window).on('scroll', _.throttle(applyScrollNavClass, 100)); + }); }).call(window); diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js index d99eefb5089..7477b5a5214 100644 --- a/app/assets/javascripts/lib/utils/ajax_cache.js +++ b/app/assets/javascripts/lib/utils/ajax_cache.js @@ -1,32 +1,44 @@ -const AjaxCache = { - internalStorage: { }, - get(endpoint) { - return this.internalStorage[endpoint]; - }, - hasData(endpoint) { - return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint); - }, - purge(endpoint) { - delete this.internalStorage[endpoint]; - }, - retrieve(endpoint) { - if (AjaxCache.hasData(endpoint)) { - return Promise.resolve(AjaxCache.get(endpoint)); +import Cache from './cache'; + +class AjaxCache extends Cache { + constructor() { + super(); + this.pendingRequests = { }; + } + + retrieve(endpoint, forceRetrieve) { + if (this.hasData(endpoint) && !forceRetrieve) { + return Promise.resolve(this.get(endpoint)); + } + + let pendingRequest = this.pendingRequests[endpoint]; + + if (!pendingRequest) { + pendingRequest = new Promise((resolve, reject) => { + // jQuery 2 is not Promises/A+ compatible (missing catch) + $.ajax(endpoint) // eslint-disable-line promise/catch-or-return + .then(data => resolve(data), + (jqXHR, textStatus, errorThrown) => { + const error = new Error(`${endpoint}: ${errorThrown}`); + error.textStatus = textStatus; + reject(error); + }, + ); + }) + .then((data) => { + this.internalStorage[endpoint] = data; + delete this.pendingRequests[endpoint]; + }) + .catch((error) => { + delete this.pendingRequests[endpoint]; + throw error; + }); + + this.pendingRequests[endpoint] = pendingRequest; } - return new Promise((resolve, reject) => { - $.ajax(endpoint) // eslint-disable-line promise/catch-or-return - .then(data => resolve(data), - (jqXHR, textStatus, errorThrown) => { - const error = new Error(`${endpoint}: ${errorThrown}`); - error.textStatus = textStatus; - reject(error); - }, - ); - }) - .then((data) => { this.internalStorage[endpoint] = data; }) - .then(() => AjaxCache.get(endpoint)); - }, -}; + return pendingRequest.then(() => this.get(endpoint)); + } +} -export default AjaxCache; +export default new AjaxCache(); diff --git a/app/assets/javascripts/lib/utils/cache.js b/app/assets/javascripts/lib/utils/cache.js new file mode 100644 index 00000000000..3141f1eeafc --- /dev/null +++ b/app/assets/javascripts/lib/utils/cache.js @@ -0,0 +1,19 @@ +class Cache { + constructor() { + this.internalStorage = { }; + } + + get(key) { + return this.internalStorage[key]; + } + + hasData(key) { + return Object.prototype.hasOwnProperty.call(this.internalStorage, key); + } + + remove(key) { + delete this.internalStorage[key]; + } +} + +export default Cache; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 2f682fbd2fb..a537267643e 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -135,7 +135,10 @@ gl.utils.getUrlParamsArray = function () { // We can trust that each param has one & since values containing & will be encoded // Remove the first character of search as it is always ? - return window.location.search.slice(1).split('&'); + return window.location.search.slice(1).split('&').map((param) => { + const split = param.split('='); + return [decodeURI(split[0]), split[1]].join('='); + }); }; gl.utils.isMetaKey = function(e) { @@ -195,10 +198,12 @@ const textBefore = value.substring(0, selectionStart); const textAfter = value.substring(selectionEnd, value.length); - const newText = textBefore + text + textAfter; + + const insertedText = text instanceof Function ? text(textBefore, textAfter) : text; + const newText = textBefore + insertedText + textAfter; target.value = newText; - target.selectionStart = target.selectionEnd = selectionStart + text.length; + target.selectionStart = target.selectionEnd = selectionStart + insertedText.length; // Trigger autosave $(target).trigger('input'); diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 82dcbdc26c8..40eadd9396c 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -1,9 +1,15 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, comma-dangle, no-unused-expressions, prefer-template, max-len */ -/* global timeago */ -/* global dateFormat */ -window.timeago = require('timeago.js'); -window.dateFormat = require('vendor/date.format'); +import timeago from 'timeago.js'; +import dateFormat from 'vendor/date.format'; + +import { + lang, + s__, +} from '../../locale'; + +window.timeago = timeago; +window.dateFormat = dateFormat; (function() { (function(w) { @@ -47,26 +53,45 @@ window.dateFormat = require('vendor/date.format'); var locale; if (!timeagoInstance) { + const localeRemaining = function(number, index) { + return [ + [s__('Timeago|less than a minute ago'), s__('Timeago|a while')], + [s__('Timeago|less than a minute ago'), s__('Timeago|%s seconds remaining')], + [s__('Timeago|about a minute ago'), s__('Timeago|1 minute remaining')], + [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], + [s__('Timeago|about an hour ago'), s__('Timeago|1 hour remaining')], + [s__('Timeago|about %s hours ago'), s__('Timeago|%s hours remaining')], + [s__('Timeago|a day ago'), s__('Timeago|1 day remaining')], + [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')], + [s__('Timeago|a week ago'), s__('Timeago|1 week remaining')], + [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')], + [s__('Timeago|a month ago'), s__('Timeago|1 month remaining')], + [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')], + [s__('Timeago|a year ago'), s__('Timeago|1 year remaining')], + [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')] + ][index]; + }; locale = function(number, index) { return [ - ['less than a minute ago', 'a while'], - ['less than a minute ago', 'in %s seconds'], - ['about a minute ago', 'in 1 minute'], - ['%s minutes ago', 'in %s minutes'], - ['about an hour ago', 'in 1 hour'], - ['about %s hours ago', 'in %s hours'], - ['a day ago', 'in 1 day'], - ['%s days ago', 'in %s days'], - ['a week ago', 'in 1 week'], - ['%s weeks ago', 'in %s weeks'], - ['a month ago', 'in 1 month'], - ['%s months ago', 'in %s months'], - ['a year ago', 'in 1 year'], - ['%s years ago', 'in %s years'] + [s__('Timeago|less than a minute ago'), s__('Timeago|a while')], + [s__('Timeago|less than a minute ago'), s__('Timeago|in %s seconds')], + [s__('Timeago|about a minute ago'), s__('Timeago|in 1 minute')], + [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], + [s__('Timeago|about an hour ago'), s__('Timeago|in 1 hour')], + [s__('Timeago|about %s hours ago'), s__('Timeago|in %s hours')], + [s__('Timeago|a day ago'), s__('Timeago|in 1 day')], + [s__('Timeago|%s days ago'), s__('Timeago|in %s days')], + [s__('Timeago|a week ago'), s__('Timeago|in 1 week')], + [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')], + [s__('Timeago|a month ago'), s__('Timeago|in 1 month')], + [s__('Timeago|%s months ago'), s__('Timeago|in %s months')], + [s__('Timeago|a year ago'), s__('Timeago|in 1 year')], + [s__('Timeago|%s years ago'), s__('Timeago|in %s years')] ][index]; }; - timeago.register('gl_en', locale); + timeago.register(lang, locale); + timeago.register(`${lang}-remaining`, localeRemaining); timeagoInstance = timeago(); } @@ -78,13 +103,11 @@ window.dateFormat = require('vendor/date.format'); if (!time) { return ''; } - suffix || (suffix = 'remaining'); - expiredLabel || (expiredLabel = 'Past due'); - timefor = gl.utils.getTimeago().format(time).replace('in', ''); - if (timefor.indexOf('ago') > -1) { + if (new Date(time) < new Date()) { + expiredLabel || (expiredLabel = s__('Timeago|Past due')); timefor = expiredLabel; } else { - timefor = timefor.trim() + ' ' + suffix; + timefor = gl.utils.getTimeago().format(time, `${lang}-remaining`).trim(); } return timefor; }; @@ -101,8 +124,7 @@ window.dateFormat = require('vendor/date.format'); }; w.gl.utils.updateTimeagoText = function(el) { - const timeago = gl.utils.getTimeago(); - const formattedDate = timeago.format(el.getAttribute('datetime'), 'gl_en'); + const formattedDate = gl.utils.getTimeago().format(el.getAttribute('datetime'), lang); if (el.textContent !== formattedDate) { el.textContent = formattedDate; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index bc109a69c20..415e50f32ae 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -2,9 +2,7 @@ * exports HTTP status codes */ -const statusCodes = { +export default { NO_CONTENT: 204, OK: 200, }; - -module.exports = statusCodes; diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js index 66f39122a66..973d6119158 100644 --- a/app/assets/javascripts/lib/utils/notify.js +++ b/app/assets/javascripts/lib/utils/notify.js @@ -1,47 +1,48 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, consistent-return, prefer-arrow-callback, no-return-assign, object-shorthand, comma-dangle, no-param-reassign, max-len */ -(function() { - (function(w) { - var notificationGranted, notifyMe, notifyPermissions; - notificationGranted = function(message, opts, onclick) { - var notification; - 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; - } - }; - notifyPermissions = function() { - if ('Notification' in window) { - return Notification.requestPermission(); - } - }; - notifyMe = function(message, body, icon, onclick) { - var opts; - opts = { - body: body, - icon: icon - }; - // Let's check if the browser supports notifications - if (!('Notification' in window)) { +function notificationGranted(message, opts, onclick) { + var notification; + notification = new Notification(message, opts); + setTimeout(function() { + // Hide the notification after X amount of seconds + return notification.close(); + }, 8000); + + return notification.onclick = onclick || notification.close; +} - // do nothing - } else if (Notification.permission === 'granted') { - // If it's okay let's create a notification +function notifyPermissions() { + if ('Notification' in window) { + return Notification.requestPermission(); + } +} + +function notifyMe(message, body, icon, onclick) { + var opts; + opts = { + 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); - } 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); - } - }); } - }; - w.notify = notifyMe; - return w.notifyPermissions = notifyPermissions; - })(window); -}).call(window); + }); + } +} + +const notify = { + notificationGranted, + notifyPermissions, + notifyMe, +}; + +export default notify; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index f1b07408671..57394097944 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -42,3 +42,13 @@ export function formatRelevantDigits(number) { export function bytesToKiB(number) { return number / BYTES_IN_KIB; } + +/** + * Utility function that calculates MiB of the given bytes. + * + * @param {Number} number bytes + * @return {Number} MiB + */ +export function bytesToMiB(number) { + return number / (BYTES_IN_KIB * BYTES_IN_KIB); +} diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index fecd531328d..601d01e1be1 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */ -require('vendor/latinise'); + +import 'vendor/latinise'; var base; var w = window; @@ -169,7 +170,7 @@ gl.text.init = function(form) { }); }; gl.text.removeListeners = function(form) { - return $('.js-md', form).off(); + return $('.js-md', form).off('click'); }; gl.text.humanize = function(string) { return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); diff --git a/app/assets/javascripts/lib/utils/type_utility.js b/app/assets/javascripts/lib/utils/type_utility.js index db62e0be324..be86f336bcd 100644 --- a/app/assets/javascripts/lib/utils/type_utility.js +++ b/app/assets/javascripts/lib/utils/type_utility.js @@ -1,15 +1,2 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, no-return-assign, max-len */ -(function() { - (function(w) { - var base; - if (w.gl == null) { - w.gl = {}; - } - if ((base = w.gl).utils == null) { - base.utils = {}; - } - return w.gl.utils.isObject = function(obj) { - return (obj != null) && (obj.constructor === Object); - }; - })(window); -}).call(window); +// eslint-disable-next-line import/prefer-default-export +export const isObject = obj => obj && obj.constructor === Object; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index b9d2fc25c39..3328ff9cc23 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -66,7 +66,8 @@ w.gl.utils.removeParamQueryString = function(url, param) { })()).join('&'); }; w.gl.utils.removeParams = (params) => { - const url = new URL(window.location.href); + const url = document.createElement('a'); + url.href = window.location.href; params.forEach((param) => { url.search = w.gl.utils.removeParamQueryString(url.search, param); }); diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js new file mode 100644 index 00000000000..88f8a622c00 --- /dev/null +++ b/app/assets/javascripts/lib/utils/users_cache.js @@ -0,0 +1,28 @@ +import Api from '../../api'; +import Cache from './cache'; + +class UsersCache extends Cache { + retrieve(username) { + if (this.hasData(username)) { + return Promise.resolve(this.get(username)); + } + + return Api.users('', { username }) + .then((users) => { + if (!users.length) { + throw new Error(`User "${username}" could not be found!`); + } + + if (users.length > 1) { + throw new Error(`Expected username "${username}" to be unique!`); + } + + const user = users[0]; + this.internalStorage[username] = user; + return user; + }); + // missing catch is intentional, error handling depends on use case + } +} + +export default new UsersCache(); diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index 3ac6dedf131..7400c22543f 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -4,8 +4,6 @@ // // Handles single- and multi-line selection and highlight for blob views. // -require('vendor/jquery.scrollTo'); - // // ### Example Markup // @@ -31,8 +29,6 @@ require('vendor/jquery.scrollTo'); // </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'; @@ -47,9 +43,9 @@ require('vendor/jquery.scrollTo'); // hash - String URL hash for dependency injection in tests hash = location.hash; } - this.setHash = bind(this.setHash, this); - this.highlightLine = bind(this.highlightLine, this); - this.clickHandler = bind(this.clickHandler, this); + this.setHash = this.setHash.bind(this); + this.highlightLine = this.highlightLine.bind(this); + this.clickHandler = this.clickHandler.bind(this); this.highlightHash = this.highlightHash.bind(this); this._hash = hash; this.bindEvents(); diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index 9411f078ecf..bcb34d90a98 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1,5 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-09 13:44+0200","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["Von"],"Commit":["Commit","Commits"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Deployment","Deployments"],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"New Issue":["Neues Issue","Neue Issues"],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"OpenedNDaysAgo|Opened":["Erstellt"],"Pipeline Health":["Pipeline Kennzahlen"],"ProjectLifecycle|Stage":["Phase"],"Read more":["Mehr"],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"You need permission.":["Sie benötigen Zugriffsrechte."],"day":["Tag","Tage"]}}};
\ No newline at end of file +<<<<<<< HEAD +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-09 13:44+0200","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["Von"],"Commit":["Commit","Commits"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Deployment","Deployments"],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"New Issue":["Neues Issue","Neue Issues"],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"OpenedNDaysAgo|Opened":["Erstellt"],"Pipeline Health":["Pipeline Kennzahlen"],"ProjectLifecycle|Stage":["Phase"],"Read more":["Mehr"],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"You need permission.":["Sie benötigen Zugriffsrechte."],"day":["Tag","Tage"]}}}; +======= +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-06-07 12:17+0200","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"Bob Van Landuyt <bob@gitlab.com>","X-Generator":"Poedit 2.0.2","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"About auto deploy":[""],"Activity":[""],"Add Changelog":[""],"Add Contribution guide":[""],"Add License":[""],"Add an SSH key to your profile to pull or push via SSH.":[""],"Add new directory":[""],"Archived project! Repository is read-only":[""],"Branch":["",""],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":[""],"Branches":[""],"ByAuthor|by":["Von"],"CI configuration":[""],"Changelog":[""],"Charts":[""],"CiStatusLabel|canceled":[""],"CiStatusLabel|created":[""],"CiStatusLabel|failed":[""],"CiStatusLabel|manual action":[""],"CiStatusLabel|passed":[""],"CiStatusLabel|passed with warnings":[""],"CiStatusLabel|pending":[""],"CiStatusLabel|skipped":[""],"CiStatusLabel|waiting for manual action":[""],"CiStatusText|blocked":[""],"CiStatusText|canceled":[""],"CiStatusText|created":[""],"CiStatusText|failed":[""],"CiStatusText|manual":[""],"CiStatusText|passed":[""],"CiStatusText|pending":[""],"CiStatusText|skipped":[""],"CiStatus|running":[""],"Commit":["Commit","Commits"],"CommitMessage|Add %{file_name}":[""],"Commits":["Commits"],"Commits|History":["Commits"],"Compare":[""],"Contribution guide":[""],"Contributors":[""],"Copy URL to clipboard":[""],"Copy commit SHA to clipboard":[""],"Create New Directory":[""],"Create directory":[""],"Create empty bare repository":[""],"Create merge request":[""],"CreateNewFork|Fork":[""],"Custom notification events":[""],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":[""],"Cycle Analytics":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Deployment","Deployments"],"Directory name":[""],"Don't show again":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadSource|Download":[""],"Files":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"ForkedFromProjectPath|Forked from":[""],"Forks":[""],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Go to your fork":[""],"GoToYourFork|Fork":[""],"Home":[""],"Housekeeping successfully started":[""],"Import repository":[""],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Last Update":[""],"Last commit":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["Neues Issue","Neue Issues"],"New branch":[""],"New directory":[""],"New file":[""],"New issue":["Neues Issue"],"New merge request":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"Notification events":[""],"NotificationEvent|Close issue":[""],"NotificationEvent|Close merge request":[""],"NotificationEvent|Failed pipeline":[""],"NotificationEvent|Merge merge request":[""],"NotificationEvent|New issue":[""],"NotificationEvent|New merge request":[""],"NotificationEvent|New note":[""],"NotificationEvent|Reassign issue":[""],"NotificationEvent|Reassign merge request":[""],"NotificationEvent|Reopen issue":[""],"NotificationEvent|Successful pipeline":[""],"NotificationLevel|Custom":[""],"NotificationLevel|Disabled":[""],"NotificationLevel|Global":[""],"NotificationLevel|On mention":[""],"NotificationLevel|Participate":[""],"NotificationLevel|Watch":[""],"OpenedNDaysAgo|Opened":["Erstellt"],"Pipeline Health":["Pipeline Kennzahlen"],"Project '%{project_name}' queued for deletion.":[""],"Project '%{project_name}' was successfully created.":[""],"Project '%{project_name}' was successfully updated.":[""],"Project '%{project_name}' will be deleted.":[""],"Project access must be granted explicitly to each user.":[""],"Project export could not be deleted.":[""],"Project export has been deleted.":[""],"Project export link has expired. Please generate a new export from your project settings.":[""],"Project export started. A download link will be sent by email.":[""],"Project home":[""],"ProjectFeature|Disabled":[""],"ProjectFeature|Everyone with access":[""],"ProjectFeature|Only team members":[""],"ProjectFileTree|Name":[""],"ProjectLastActivity|Never":[""],"ProjectLifecycle|Stage":["Phase"],"ProjectNetworkGraph|Graph":[""],"Read more":["Mehr"],"Readme":[""],"RefSwitcher|Branches":[""],"RefSwitcher|Tags":[""],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Remind later":[""],"Remove project":[""],"Request Access":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Set a password on your account to pull or push via %{protocol}":[""],"Set up CI":[""],"Set up Koding":[""],"Set up auto deploy":[""],"SetPasswordToCloneLink|set a password":[""],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"Source code":[""],"StarProject|Star":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"The fork relationship has been removed.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"The project can be accessed by any logged in user.":[""],"The project can be accessed without any authentication.":[""],"The repository for this project does not exist.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"This means you can not push code until you create an empty repository or import existing one.":[""],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Timeago|%s days ago":[""],"Timeago|%s days remaining":[""],"Timeago|%s hours remaining":[""],"Timeago|%s minutes ago":[""],"Timeago|%s minutes remaining":[""],"Timeago|%s months ago":[""],"Timeago|%s months remaining":[""],"Timeago|%s seconds remaining":[""],"Timeago|%s weeks ago":[""],"Timeago|%s weeks remaining":[""],"Timeago|%s years ago":[""],"Timeago|%s years remaining":[""],"Timeago|1 day remaining":[""],"Timeago|1 hour remaining":[""],"Timeago|1 minute remaining":[""],"Timeago|1 month remaining":[""],"Timeago|1 week remaining":[""],"Timeago|1 year remaining":[""],"Timeago|Past due":[""],"Timeago|a day ago":[""],"Timeago|a month ago":[""],"Timeago|a week ago":[""],"Timeago|a while":[""],"Timeago|a year ago":[""],"Timeago|about %s hours ago":[""],"Timeago|about a minute ago":[""],"Timeago|about an hour ago":[""],"Timeago|in %s days":[""],"Timeago|in %s hours":[""],"Timeago|in %s minutes":[""],"Timeago|in %s months":[""],"Timeago|in %s seconds":[""],"Timeago|in %s weeks":[""],"Timeago|in %s years":[""],"Timeago|in 1 day":[""],"Timeago|in 1 hour":[""],"Timeago|in 1 minute":[""],"Timeago|in 1 month":[""],"Timeago|in 1 week":[""],"Timeago|in 1 year":[""],"Timeago|less than a minute ago":[""],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Unstar":[""],"Upload New File":[""],"Upload file":[""],"Use your global notification setting":[""],"VisibilityLevel|Internal":[""],"VisibilityLevel|Private":[""],"VisibilityLevel|Public":[""],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"Withdraw Access Request":[""],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":[""],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":[""],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":[""],"You can only add files when you are on a branch":[""],"You must sign in to star a project":[""],"You need permission.":["Sie benötigen Zugriffsrechte."],"You will not get any notifications via email":[""],"You will only receive notifications for the events you choose":[""],"You will only receive notifications for threads you have participated in":[""],"You will receive notifications for any activity":[""],"You will receive notifications only for comments in which you were @mentioned":[""],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":[""],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":[""],"Your name":[""],"committed":[""],"day":["Tag","Tage"],"notification emails":[""]}}}; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index ade9b667b3c..80203308b00 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}};
\ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-06-07 12:14+0200","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"Bob Van Landuyt <bob@gitlab.com>","X-Generator":"Poedit 2.0.2","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"About auto deploy":[""],"Activity":[""],"Add Changelog":[""],"Add Contribution guide":[""],"Add License":[""],"Add an SSH key to your profile to pull or push via SSH.":[""],"Add new directory":[""],"Archived project! Repository is read-only":[""],"Branch":["",""],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":[""],"Branches":[""],"ByAuthor|by":[""],"CI configuration":[""],"Changelog":[""],"Charts":[""],"CiStatusLabel|canceled":[""],"CiStatusLabel|created":[""],"CiStatusLabel|failed":[""],"CiStatusLabel|manual action":[""],"CiStatusLabel|passed":[""],"CiStatusLabel|passed with warnings":[""],"CiStatusLabel|pending":[""],"CiStatusLabel|skipped":[""],"CiStatusLabel|waiting for manual action":[""],"CiStatusText|blocked":[""],"CiStatusText|canceled":[""],"CiStatusText|created":[""],"CiStatusText|failed":[""],"CiStatusText|manual":[""],"CiStatusText|passed":[""],"CiStatusText|pending":[""],"CiStatusText|skipped":[""],"CiStatus|running":[""],"Commit":["",""],"CommitMessage|Add %{file_name}":[""],"Commits":[""],"Commits|History":[""],"Compare":[""],"Contribution guide":[""],"Contributors":[""],"Copy URL to clipboard":[""],"Copy commit SHA to clipboard":[""],"Create New Directory":[""],"Create directory":[""],"Create empty bare repository":[""],"Create merge request":[""],"CreateNewFork|Fork":[""],"Custom notification events":[""],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":[""],"Cycle Analytics":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"Directory name":[""],"Don't show again":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadSource|Download":[""],"Files":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"ForkedFromProjectPath|Forked from":[""],"Forks":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Go to your fork":[""],"GoToYourFork|Fork":[""],"Home":[""],"Housekeeping successfully started":[""],"Import repository":[""],"Introducing Cycle Analytics":[""],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["",""],"Last Update":[""],"Last commit":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["",""],"Median":[""],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["",""],"New branch":[""],"New directory":[""],"New file":[""],"New issue":[""],"New merge request":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"Not available":[""],"Not enough data":[""],"Notification events":[""],"NotificationEvent|Close issue":[""],"NotificationEvent|Close merge request":[""],"NotificationEvent|Failed pipeline":[""],"NotificationEvent|Merge merge request":[""],"NotificationEvent|New issue":[""],"NotificationEvent|New merge request":[""],"NotificationEvent|New note":[""],"NotificationEvent|Reassign issue":[""],"NotificationEvent|Reassign merge request":[""],"NotificationEvent|Reopen issue":[""],"NotificationEvent|Successful pipeline":[""],"NotificationLevel|Custom":[""],"NotificationLevel|Disabled":[""],"NotificationLevel|Global":[""],"NotificationLevel|On mention":[""],"NotificationLevel|Participate":[""],"NotificationLevel|Watch":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"Project '%{project_name}' queued for deletion.":[""],"Project '%{project_name}' was successfully created.":[""],"Project '%{project_name}' was successfully updated.":[""],"Project '%{project_name}' will be deleted.":[""],"Project access must be granted explicitly to each user.":[""],"Project export could not be deleted.":[""],"Project export has been deleted.":[""],"Project export link has expired. Please generate a new export from your project settings.":[""],"Project export started. A download link will be sent by email.":[""],"Project home":[""],"ProjectFeature|Disabled":[""],"ProjectFeature|Everyone with access":[""],"ProjectFeature|Only team members":[""],"ProjectFileTree|Name":[""],"ProjectLastActivity|Never":[""],"ProjectLifecycle|Stage":[""],"ProjectNetworkGraph|Graph":[""],"Read more":[""],"Readme":[""],"RefSwitcher|Branches":[""],"RefSwitcher|Tags":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Remind later":[""],"Remove project":[""],"Request Access":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Set a password on your account to pull or push via %{protocol}":[""],"Set up CI":[""],"Set up Koding":[""],"Set up auto deploy":[""],"SetPasswordToCloneLink|set a password":[""],"Showing %d event":["",""],"Source code":[""],"StarProject|Star":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The fork relationship has been removed.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The project can be accessed by any logged in user.":[""],"The project can be accessed without any authentication.":[""],"The repository for this project does not exist.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"This means you can not push code until you create an empty repository or import existing one.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Timeago|%s days ago":[""],"Timeago|%s days remaining":[""],"Timeago|%s hours remaining":[""],"Timeago|%s minutes ago":[""],"Timeago|%s minutes remaining":[""],"Timeago|%s months ago":[""],"Timeago|%s months remaining":[""],"Timeago|%s seconds remaining":[""],"Timeago|%s weeks ago":[""],"Timeago|%s weeks remaining":[""],"Timeago|%s years ago":[""],"Timeago|%s years remaining":[""],"Timeago|1 day remaining":[""],"Timeago|1 hour remaining":[""],"Timeago|1 minute remaining":[""],"Timeago|1 month remaining":[""],"Timeago|1 week remaining":[""],"Timeago|1 year remaining":[""],"Timeago|Past due":[""],"Timeago|a day ago":[""],"Timeago|a month ago":[""],"Timeago|a week ago":[""],"Timeago|a while":[""],"Timeago|a year ago":[""],"Timeago|about %s hours ago":[""],"Timeago|about a minute ago":[""],"Timeago|about an hour ago":[""],"Timeago|in %s days":[""],"Timeago|in %s hours":[""],"Timeago|in %s minutes":[""],"Timeago|in %s months":[""],"Timeago|in %s seconds":[""],"Timeago|in %s weeks":[""],"Timeago|in %s years":[""],"Timeago|in 1 day":[""],"Timeago|in 1 hour":[""],"Timeago|in 1 minute":[""],"Timeago|in 1 month":[""],"Timeago|in 1 week":[""],"Timeago|in 1 year":[""],"Timeago|less than a minute ago":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Unstar":[""],"Upload New File":[""],"Upload file":[""],"Use your global notification setting":[""],"VisibilityLevel|Internal":[""],"VisibilityLevel|Private":[""],"VisibilityLevel|Public":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"Withdraw Access Request":[""],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":[""],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":[""],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":[""],"You can only add files when you are on a branch":[""],"You must sign in to star a project":[""],"You need permission.":[""],"You will not get any notifications via email":[""],"You will only receive notifications for the events you choose":[""],"You will only receive notifications for threads you have participated in":[""],"You will receive notifications for any activity":[""],"You will receive notifications only for comments in which you were @mentioned":[""],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":[""],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":[""],"Your name":[""],"committed":[""],"day":["",""],"notification emails":[""]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index 3dafa21f235..6977625f4d8 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-04 19:24-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d día","Últimos %d días"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Deployed Jobs":["Trabajos Desplegados Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Jobs":["Trabajos Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Related Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["día","días"]}}};
\ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-06-07 12:29-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"Bob Van Landuyt <bob@gitlab.com>","X-Generator":"Poedit 2.0.2","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"About auto deploy":["Acerca del auto despliegue"],"Activity":["Actividad"],"Add Changelog":["Agregar Changelog"],"Add Contribution guide":["Agregar guía de contribución"],"Add License":["Agregar Licencia"],"Add an SSH key to your profile to pull or push via SSH.":["Agregar una clave SSH a tu perfil para actualizar o enviar a través de SSH."],"Add new directory":["Agregar nuevo directorio"],"Archived project! Repository is read-only":["¡Proyecto archivado! El repositorio es de sólo lectura"],"Branch":["Rama","Ramas"],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":["La rama <strong>%{branch_name}</strong> fue creada. Para configurar el auto despliegue, escoge una plantilla Yaml para GitLab CI y envía tus cambios. %{link_to_autodeploy_doc}"],"Branches":["Ramas"],"ByAuthor|by":["por"],"CI configuration":["Configuración de CI"],"Changelog":["Changelog"],"Charts":["Gráficos"],"CiStatusLabel|canceled":["cancelado"],"CiStatusLabel|created":["creado"],"CiStatusLabel|failed":["fallado"],"CiStatusLabel|manual action":["acción manual"],"CiStatusLabel|passed":["pasó"],"CiStatusLabel|passed with warnings":["pasó con advertencias"],"CiStatusLabel|pending":["pendiente"],"CiStatusLabel|skipped":["omitido"],"CiStatusLabel|waiting for manual action":["esperando acción manual"],"CiStatusText|blocked":["bloqueado"],"CiStatusText|canceled":["cancelado"],"CiStatusText|created":["creado"],"CiStatusText|failed":["fallado"],"CiStatusText|manual":["manual"],"CiStatusText|passed":["pasó"],"CiStatusText|pending":["pendiente"],"CiStatusText|skipped":["omitido"],"CiStatus|running":["en ejecución"],"Commit":["Cambio","Cambios"],"CommitMessage|Add %{file_name}":["Agregar %{file_name}"],"Commits":["Cambios"],"Commits|History":["Historial"],"Compare":["Comparar"],"Contribution guide":["Guía de contribución"],"Contributors":["Contribuidores"],"Copy URL to clipboard":["Copiar URL al portapapeles"],"Copy commit SHA to clipboard":["Copiar SHA del cambio al portapapeles"],"Create New Directory":["Crear Nuevo Directorio"],"Create directory":["Crear directorio"],"Create empty bare repository":["Crear repositorio vacío"],"Create merge request":["Crear solicitud de fusión"],"CreateNewFork|Fork":["Bifurcar"],"Custom notification events":["Eventos de notificaciones personalizadas"],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":["Los niveles de notificación personalizados son los mismos que los niveles participantes. Con los niveles de notificación personalizados, también recibirá notificaciones para eventos seleccionados. Para obtener más información, consulte %{notification_link}."],"Cycle Analytics":["Cycle Analytics"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"Directory name":["Nombre del directorio"],"Don't show again":["No mostrar de nuevo"],"Download tar":["Descargar tar"],"Download tar.bz2":["Descargar tar.bz2"],"Download tar.gz":["Descargar tar.gz"],"Download zip":["Descargar zip"],"DownloadArtifacts|Download":["Descargar"],"DownloadSource|Download":["Descargar"],"Files":["Archivos"],"Find by path":["Buscar por ruta"],"Find file":["Buscar archivo"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"ForkedFromProjectPath|Forked from":["Bifurcado de"],"Forks":["Bifurcaciones"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Go to your fork":["Ir a tu bifurcación"],"GoToYourFork|Fork":["Bifurcación"],"Home":["Inicio"],"Housekeeping successfully started":["Servicio de limpieza iniciado con éxito"],"Import repository":["Importar repositorio"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"LFSStatus|Disabled":["Deshabilitado"],"LFSStatus|Enabled":["Habilitado"],"Last %d day":["Último %d día","Últimos %d días"],"Last Update":["Última actualización"],"Last commit":["Último cambio"],"Leave group":["Abandonar grupo"],"Leave project":["Abandonar proyecto"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"MissingSSHKeyWarningLink|add an SSH key":["agregar una clave SSH"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"New branch":["Nueva rama"],"New directory":["Nuevo directorio"],"New file":["Nuevo archivo"],"New issue":["Nueva incidencia"],"New merge request":["Nueva solicitud de fusión"],"New snippet":["Nuevo fragmento de código"],"New tag":["Nueva etiqueta"],"No repository":["No hay repositorio"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Notification events":["Eventos de notificación"],"NotificationEvent|Close issue":["Cerrar incidencia"],"NotificationEvent|Close merge request":["Cerrar solicitud de fusión"],"NotificationEvent|Failed pipeline":["Pipeline fallido"],"NotificationEvent|Merge merge request":["Integrar solicitud de fusión"],"NotificationEvent|New issue":["Nueva incidencia"],"NotificationEvent|New merge request":["Nueva solicitud de fusión"],"NotificationEvent|New note":["Nueva nota"],"NotificationEvent|Reassign issue":["Reasignar incidencia"],"NotificationEvent|Reassign merge request":["Reasignar solicitud de fusión"],"NotificationEvent|Reopen issue":["Reabrir incidencia"],"NotificationEvent|Successful pipeline":["Pipeline exitoso"],"NotificationLevel|Custom":["Personalizado"],"NotificationLevel|Disabled":["Deshabilitado"],"NotificationLevel|Global":["Global"],"NotificationLevel|On mention":["Cuando me mencionan"],"NotificationLevel|Participate":["Participación"],"NotificationLevel|Watch":["Vigilancia"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"Project '%{project_name}' queued for deletion.":["Proyecto ‘%{project_name}’ en cola para eliminación."],"Project '%{project_name}' was successfully created.":["Proyecto ‘%{project_name}’ fue creado satisfactoriamente."],"Project '%{project_name}' was successfully updated.":["Proyecto ‘%{project_name}’ fue actualizado satisfactoriamente."],"Project '%{project_name}' will be deleted.":["Proyecto ‘%{project_name}’ será eliminado."],"Project access must be granted explicitly to each user.":["El acceso al proyecto debe concederse explícitamente a cada usuario."],"Project export could not be deleted.":["No se pudo eliminar la exportación del proyecto."],"Project export has been deleted.":["La exportación del proyecto ha sido eliminada."],"Project export link has expired. Please generate a new export from your project settings.":["El enlace de exportación del proyecto ha caducado. Por favor, genera una nueva exportación desde la configuración del proyecto."],"Project export started. A download link will be sent by email.":["Se inició la exportación del proyecto. Se enviará un enlace de descarga por correo electrónico."],"Project home":["Inicio del proyecto"],"ProjectFeature|Disabled":["Deshabilitada"],"ProjectFeature|Everyone with access":["Todos con acceso"],"ProjectFeature|Only team members":["Solo miembros del equipo"],"ProjectFileTree|Name":["Nombre"],"ProjectLastActivity|Never":["Nunca"],"ProjectLifecycle|Stage":["Etapa"],"ProjectNetworkGraph|Graph":["Historial gráfico"],"Read more":["Leer más"],"Readme":["Readme"],"RefSwitcher|Branches":["Ramas"],"RefSwitcher|Tags":["Etiquetas"],"Related Commits":["Cambios Relacionados"],"Related Deployed Jobs":["Trabajos Desplegados Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Jobs":["Trabajos Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Related Merged Requests":["Solicitudes de fusión Relacionadas"],"Remind later":["Recordar después"],"Remove project":["Eliminar proyecto"],"Request Access":["Solicitar acceso"],"Search branches and tags":["Buscar ramas y etiquetas"],"Select Archive Format":["Seleccionar formato de archivo"],"Set a password on your account to pull or push via %{protocol}":["Establezca una contraseña en su cuenta para actualizar o enviar a través de% {protocol}"],"Set up CI":["Configurar CI"],"Set up Koding":["Configurar Koding"],"Set up auto deploy":["Configurar auto despliegue"],"SetPasswordToCloneLink|set a password":["establecer una contraseña"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Source code":["Código fuente"],"StarProject|Star":["Destacar"],"Switch branch/tag":["Cambiar rama/etiqueta"],"Tag":["Etiqueta","Etiquetas"],"Tags":["Etiquetas"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The fork relationship has been removed.":["La relación con la bifurcación se ha eliminado."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The project can be accessed by any logged in user.":["El proyecto puede ser accedido por cualquier usuario conectado."],"The project can be accessed without any authentication.":["El proyecto puede accederse sin ninguna autenticación."],"The repository for this project does not exist.":["El repositorio para este proyecto no existe."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"This means you can not push code until you create an empty repository or import existing one.":["Esto significa que no puede enviar código hasta que cree un repositorio vacío o importe uno existente."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Timeago|%s days ago":["hace %s días"],"Timeago|%s days remaining":["%s días restantes"],"Timeago|%s hours remaining":["%s horas restantes"],"Timeago|%s minutes ago":["hace %s minutos"],"Timeago|%s minutes remaining":["%s minutos restantes"],"Timeago|%s months ago":["hace %s meses"],"Timeago|%s months remaining":["%s meses restantes"],"Timeago|%s seconds remaining":["%s segundos restantes"],"Timeago|%s weeks ago":["hace %s semanas"],"Timeago|%s weeks remaining":["%s semanas restantes"],"Timeago|%s years ago":["hace %s años"],"Timeago|%s years remaining":["%s años restantes"],"Timeago|1 day remaining":["1 día restante"],"Timeago|1 hour remaining":["1 hora restante"],"Timeago|1 minute remaining":["1 minuto restante"],"Timeago|1 month remaining":["1 mes restante"],"Timeago|1 week remaining":["1 semana restante"],"Timeago|1 year remaining":["1 año restante"],"Timeago|Past due":["Atrasado"],"Timeago|a day ago":["hace un día"],"Timeago|a month ago":["hace 1 mes"],"Timeago|a week ago":["hace 1 semana"],"Timeago|a while":["hace un momento"],"Timeago|a year ago":["hace 1 año"],"Timeago|about %s hours ago":["hace alrededor de %s horas"],"Timeago|about a minute ago":["hace alrededor de 1 minuto"],"Timeago|about an hour ago":["hace alrededor de 1 hora"],"Timeago|in %s days":["en %s días"],"Timeago|in %s hours":["en %s horas"],"Timeago|in %s minutes":["en %s minutos"],"Timeago|in %s months":["en %s meses"],"Timeago|in %s seconds":["en %s segundos"],"Timeago|in %s weeks":["en %s semanas"],"Timeago|in %s years":["en %s años"],"Timeago|in 1 day":["en 1 día"],"Timeago|in 1 hour":["en 1 hora"],"Timeago|in 1 minute":["en 1 minuto"],"Timeago|in 1 month":["en 1 mes"],"Timeago|in 1 week":["en 1 semana"],"Timeago|in 1 year":["en 1 año"],"Timeago|less than a minute ago":["hace menos de 1 minuto"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Unstar":["No Destacar"],"Upload New File":["Subir nuevo archivo"],"Upload file":["Subir archivo"],"Use your global notification setting":["Utiliza tu configuración de notificación global"],"VisibilityLevel|Internal":["Interno"],"VisibilityLevel|Private":["Privado"],"VisibilityLevel|Public":["Público"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"Withdraw Access Request":["Retirar Solicitud de Acceso"],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":["Va a eliminar %{project_name_with_namespace}.\\n¡El proyecto eliminado NO puede ser restaurado!\\n¿Estás TOTALMENTE seguro?"],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":["Vas a eliminar el enlace de la bifurcación con el proyecto original %{forked_from_project}. ¿Estás TOTALMENTE seguro?"],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":["Vas a transferir %{project_name_with_namespace} a otro propietario. ¿Estás TOTALMENTE seguro?"],"You can only add files when you are on a branch":["Sólo puede agregar archivos cuando estas en una rama"],"You must sign in to star a project":["Debes iniciar sesión para destacar un proyecto"],"You need permission.":["Necesitas permisos."],"You will not get any notifications via email":["No recibirás ninguna notificación por correo electrónico"],"You will only receive notifications for the events you choose":["Solo recibirás notificaciones de los eventos que elijas"],"You will only receive notifications for threads you have participated in":["Solo recibirás notificaciones de los temas en los que has participado"],"You will receive notifications for any activity":["Recibirás notificaciones para cualquier actividad"],"You will receive notifications only for comments in which you were @mentioned":["Recibirás notificaciones sólo para los comentarios en los que se te mencionó"],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":["No podrás actualizar o enviar código al proyecto a través de %{protocol} hasta que %{set_password_link} en tu cuenta"],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":["No podrás actualizar o enviar código al proyecto a través de SSH hasta que %{add_ssh_key_link} en su perfil"],"Your name":["Tu nombre"],"committed":["cambió"],"day":["día","días"],"notification emails":["correos electrónicos de notificación"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/zh_CN/app.js b/app/assets/javascripts/locale/zh_CN/app.js new file mode 100644 index 00000000000..9525bc88190 --- /dev/null +++ b/app/assets/javascripts/locale/zh_CN/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['zh_CN'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (China) (https://www.transifex.com/gitlab-zh/teams/75177/zh_CN/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_CN","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_CN","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["提交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["周期分析概述了项目从想法到产品实现的各阶段所需的时间。"],"CycleAnalyticsStage|Code":["编码"],"CycleAnalyticsStage|Issue":["议题"],"CycleAnalyticsStage|Plan":["计划"],"CycleAnalyticsStage|Production":["生产"],"CycleAnalyticsStage|Review":["评审"],"CycleAnalyticsStage|Staging":["预发布"],"CycleAnalyticsStage|Test":["测试"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["从创建议题到部署至生产环境"],"From merge request merge until deploy to production":["从合并请求被合并后到部署至生产环境"],"Introducing Cycle Analytics":["周期分析简介"],"Last %d day":["最后 %d 天"],"Limited to showing %d event at most":["最多显示 %d 个事件"],"Median":["中位数"],"New Issue":["新议题"],"Not available":["数据不足"],"Not enough data":["数据不足"],"OpenedNDaysAgo|Opened":["开始于"],"Pipeline Health":["流水线健康指标"],"ProjectLifecycle|Stage":["项目生命周期"],"Read more":["了解更多"],"Related Commits":["相关的提交"],"Related Deployed Jobs":["相关的部署作业"],"Related Issues":["相关的议题"],"Related Jobs":["相关的作业"],"Related Merge Requests":["相关的合并请求"],"Related Merged Requests":["相关已合并的合并请求"],"Showing %d event":["显示 %d 个事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["编码阶段概述了从第一次提交到创建合并请求的时间。创建第一个合并请求后,数据将自动添加到此处。"],"The collection of events added to the data gathered for that stage.":["与该阶段相关的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["议题阶段概述了从创建议题到将议题设置里程碑或将议题添加到议题看板的时间。开始创建议题以查看此阶段的数据。"],"The phase of the development lifecycle.":["项目生命周期中的各个阶段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["计划阶段概述了从议题添加到日程后到推送首次提交的时间。当首次推送提交后,数据将自动添加到此处。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["生产阶段概述了从创建一个议题到将代码部署到生产环境的总时间。当完成想法到部署生产的循环,数据将自动添加到此处。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["评审阶段概述了从创建合并请求到被合并的时间。当创建第一个合并请求后,数据将自动添加到此处。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["预发布阶段概述了从合并请求被合并到部署至生产环境的总时间。首次部署到生产环境后,数据将自动添加到此处。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。"],"The time taken by each data entry gathered by that stage.":["该阶段每条数据所花的时间"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["议题被列入日程表的时间"],"Time before an issue starts implementation":["开始进行编码前的时间"],"Time between merge request creation and merge/close":["从创建合并请求到被合并或关闭的时间"],"Time until first merge request":["创建第一个合并请求之前的时间"],"Time|hr":["小时"],"Time|min":["分钟"],"Time|s":["秒"],"Total Time":["总时间"],"Total test time for all commits/merges":["所有提交和合并的总测试时间"],"Want to see the data? Please ask an administrator for access.":["权限不足。如需查看相关数据,请向管理员申请权限。"],"We don't have enough data to show this stage.":["该阶段的数据不足,无法显示。"],"You need permission.":["您需要相关的权限。"],"day":["天"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/zh_HK/app.js b/app/assets/javascripts/locale/zh_HK/app.js new file mode 100644 index 00000000000..fd0bcd988c5 --- /dev/null +++ b/app/assets/javascripts/locale/zh_HK/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['zh_HK'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/75177/zh_HK/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_HK","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_HK","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["提交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了項目從想法到產品實現的各階段所需的時間。"],"CycleAnalyticsStage|Code":["編碼"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["生產"],"CycleAnalyticsStage|Review":["評審"],"CycleAnalyticsStage|Staging":["預發布"],"CycleAnalyticsStage|Test":["測試"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從創建議題到部署到生產環境"],"From merge request merge until deploy to production":["從合併請求的合併到部署至生產環境"],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"Not available":["不可用"],"Not enough data":["數據不足"],"OpenedNDaysAgo|Opened":["開始於"],"Pipeline Health":["流水線健康指標"],"ProjectLifecycle|Stage":["項目生命週期"],"Read more":["了解更多"],"Related Commits":["相關的提交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的合並請求"],"Showing %d event":["顯示 %d 個事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["編碼階段概述了從第一次提交到創建合併請求的時間。創建第壹個合並請求後,數據將自動添加到此處。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["議題階段概述了從創建議題到將議題設置裏程碑或將議題添加到議題看板的時間。創建一個議題後,數據將自動添加到此處。"],"The phase of the development lifecycle.":["項目生命週期中的各個階段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["計劃階段概述了從議題添加到日程後到推送首次提交的時間。當首次推送提交後,數據將自動添加到此處。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完成完整的想法到部署生產,數據將自動添加到此處。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["評審階段概述了從創建合並請求到合併的時間。當創建第壹個合並請求後,數據將自動添加到此處。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["預發布階段概述了合並請求的合併到部署代碼到生產環境的總時間。當首次部署到生產環境後,數據將自動添加到此處。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。"],"The time taken by each data entry gathered by that stage.":["該階段每條數據所花的時間"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["開始進行編碼前的時間"],"Time between merge request creation and merge/close":["從創建合併請求到被合並或關閉的時間"],"Time until first merge request":["創建第壹個合併請求之前的時間"],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有提交和合併的總測試時間"],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關數據,請向管理員申請權限。"],"We don't have enough data to show this stage.":["該階段的數據不足,無法顯示。"],"You need permission.":["您需要相關的權限。"],"day":["天"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/zh_TW/app.js b/app/assets/javascripts/locale/zh_TW/app.js new file mode 100644 index 00000000000..79904d17bf6 --- /dev/null +++ b/app/assets/javascripts/locale/zh_TW/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['zh_TW'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/75177/zh_TW/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_TW","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_TW","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["送交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了你的專案從想法到產品實現,各階段所需的時間。"],"CycleAnalyticsStage|Code":["程式開發"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["上線"],"CycleAnalyticsStage|Review":["複閱"],"CycleAnalyticsStage|Staging":["預備"],"CycleAnalyticsStage|Test":["測試"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從議題建立至線上部署"],"From merge request merge until deploy to production":["從請求被合併後至線上部署"],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"Not available":["無法使用"],"Not enough data":["資料不足"],"OpenedNDaysAgo|Opened":["開始於"],"Pipeline Health":["流水線健康指標"],"ProjectLifecycle|Stage":["專案生命週期"],"Read more":["了解更多"],"Related Commits":["相關的送交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的請求"],"Showing %d event":["顯示 %d 個事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。"],"The phase of the development lifecycle.":["專案開發生命週期的各個階段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["計劃階段顯示從議題添加到日程後至推送第一個送交的時間。當第一次推送送交後,資料將自動填入。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。"],"The time taken by each data entry gathered by that stage.":["每筆該階段相關資料所花的時間。"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["議題等待開始實作的時間"],"Time between merge request creation and merge/close":["合併請求被合併或是關閉的時間"],"Time until first merge request":["第一個合併請求被建立前的時間"],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有送交和合併的總測試時間"],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關資料,請向管理員申請權限。"],"We don't have enough data to show this stage.":["因該階段的資料不足而無法顯示相關資訊"],"You need permission.":["您需要相關的權限。"],"day":["天"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index a07aa047293..fe367d0c42a 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -56,10 +56,8 @@ import './lib/utils/animate'; import './lib/utils/bootstrap_linked_tabs'; import './lib/utils/common_utils'; import './lib/utils/datetime_utility'; -import './lib/utils/notify'; import './lib/utils/pretty_time'; import './lib/utils/text_utility'; -import './lib/utils/type_utility'; import './lib/utils/url_utility'; // u2f @@ -97,7 +95,6 @@ import './dropzone_input'; import './due_date_select'; import './files_comment_button'; import './flash'; -import './gfm_auto_complete'; import './gl_dropdown'; import './gl_field_error'; import './gl_field_errors'; @@ -107,12 +104,11 @@ import './group_label_subscription'; import './groups_select'; import './header'; import './importer_status'; -import './issuable'; +import './issuable_index'; import './issuable_context'; import './issuable_form'; import './issue'; import './issue_status_select'; -import './issues_bulk_assignment'; import './label_manager'; import './labels'; import './labels_select'; @@ -172,7 +168,7 @@ import './visibility_select'; import './wikis'; import './zen_mode'; -// eslint-disable-next-line global-require +// eslint-disable-next-line global-require, import/no-commonjs if (process.env.NODE_ENV !== 'production') require('./test_utils/'); document.addEventListener('beforeunload', function () { diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index 15992460146..17030c3e4d3 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -2,14 +2,13 @@ /* global Flash */ import Vue from 'vue'; - -require('./merge_conflict_store'); -require('./merge_conflict_service'); -require('./mixins/line_conflict_utils'); -require('./mixins/line_conflict_actions'); -require('./components/diff_file_editor'); -require('./components/inline_conflict_lines'); -require('./components/parallel_conflict_lines'); +import './merge_conflict_store'; +import './merge_conflict_service'; +import './mixins/line_conflict_utils'; +import './mixins/line_conflict_actions'; +import './components/diff_file_editor'; +import './components/inline_conflict_lines'; +import './components/parallel_conflict_lines'; $(() => { const INTERACTIVE_RESOLVE_MODE = 'interactive'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index ed342b9990f..f93feeec1c2 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,13 +1,11 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, max-len, prefer-arrow-callback */ /* global MergeRequestTabs */ -require('vendor/jquery.waitforimages'); -require('./task_list'); -require('./merge_request_tabs'); +import 'vendor/jquery.waitforimages'; +import './task_list'; +import './merge_request_tabs'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.MergeRequest = (function() { function MergeRequest(opts) { // Initialize MergeRequest behavior @@ -16,7 +14,7 @@ require('./merge_request_tabs'); // action - String, current controller action // this.opts = opts != null ? opts : {}; - this.submitNoteForm = bind(this.submitNoteForm, this); + this.submitNoteForm = this.submitNoteForm.bind(this); this.$el = $('.merge-request'); this.$('.show-all-commits').on('click', (function(_this) { return function() { diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index c709730f78f..4503171c887 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -285,7 +285,11 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; // Similar to `toggler_behavior` in the discussion tab const hash = window.gl.utils.getLocationHash(); const anchor = hash && $container.find(`[id="${hash}"]`); +<<<<<<< HEAD if (anchor) { +======= + if (anchor && anchor.length > 0) { +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 const notesContent = anchor.closest('.notes_content'); const lineType = notesContent.hasClass('new') ? 'new' : 'old'; notes.toggleDiffNote({ @@ -373,18 +377,26 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; initAffix() { const $tabs = $('.js-tabs-affix'); + const $fixedNav = $('.navbar-gitlab'); // Screen space on small screens is usually very sparse // So we dont affix the tabs on these if (Breakpoints.get().getBreakpointSize() === 'xs' || !$tabs.length) return; + /** + If the browser does not support position sticky, it returns the position as static. + If the browser does support sticky, then we allow the browser to handle it, if not + then we default back to Bootstraps affix + **/ + if ($tabs.css('position') !== 'static') return; + const $diffTabs = $('#diff-notes-app'); $tabs.off('affix.bs.affix affix-top.bs.affix') .affix({ offset: { top: () => ( - $diffTabs.offset().top - $tabs.height() + $diffTabs.offset().top - $tabs.height() - $fixedNav.height() ), }, }) diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js deleted file mode 100644 index 6f6ae9bde92..00000000000 --- a/app/assets/javascripts/merge_request_widget.js +++ /dev/null @@ -1,305 +0,0 @@ -/* eslint-disable max-len, no-var, func-names, space-before-function-paren, vars-on-top, comma-dangle, no-return-assign, consistent-return, no-param-reassign, one-var, one-var-declaration-per-line, quotes, prefer-template, no-else-return, prefer-arrow-callback, no-unused-vars, no-underscore-dangle, no-shadow, no-mixed-operators, camelcase, default-case, wrap-iife */ -/* global notify */ -/* global notifyPermissions */ -/* global merge_request_widget */ - -import './smart_interval'; -import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; - -((global) => { - var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; - - 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" rel="noopener noreferrer" 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" rel="noopener noreferrer"> - <i class="fa fa-external-link"></i> - View on <%- external_url_formatted %> - </a> - </span> - <span class="stop-env-container js-stop-env-link"> - <a href="<%- stop_url %>" class="close-evn-link" data-method="post" rel="nofollow" data-confirm="Are you sure you want to stop this environment?"> - <i class="fa fa-stop-circle-o"/> - Stop environment - </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 - // pipeline_status_url - String, URL to use to get CI status for Favicon - // - this.opts = opts; - this.opts.pipeline_status_url = `${this.opts.pipeline_status_url}.json`; - this.$widgetBody = $('.mr-widget-body'); - $('#modal_merge_info').modal({ - show: false - }); - this.clearEventListeners(); - this.addEventListeners(); - this.getCIStatus(false); - this.retrieveSuccessIcon(); - - this.initMiniPipelineGraph(); - - this.ciStatusInterval = new global.SmartInterval({ - callback: this.getCIStatus.bind(this, true), - startingInterval: 10000, - maxInterval: 30000, - hiddenInterval: 120000, - incrementByFactorOf: 5000, - }); - this.ciEnvironmentStatusInterval = new global.SmartInterval({ - callback: this.getCIEnvironmentsStatus.bind(this), - startingInterval: 30000, - maxInterval: 120000, - hiddenInterval: 240000, - incrementByFactorOf: 15000, - immediateExecution: true, - }); - - notifyPermissions(); - } - - MergeRequestWidget.prototype.clearEventListeners = function() { - return $(document).off('DOMContentLoaded'); - }; - - MergeRequestWidget.prototype.addEventListeners = function() { - var allowedPages; - allowedPages = ['show', 'commits', 'pipelines', 'changes']; - $(document).on('DOMContentLoaded', (function(_this) { - return function() { - var page; - page = $('body').data('page').split(':').last(); - if (allowedPages.indexOf(page) === -1) { - return _this.clearEventListeners(); - } - }; - })(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; - } - return $.ajax({ - type: 'GET', - url: $('.merge-request').data('url'), - success: (function(_this) { - return function(data) { - var callback, urlSuffix; - if (data.state === "merged") { - 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>"); - } else { - callback = function() { - return merge_request_widget.mergeInProgress(deleteSourceBranch); - }; - return setTimeout(callback, 2000); - } - }; - })(this), - dataType: 'json' - }); - }; - - MergeRequestWidget.prototype.cancelPolling = function () { - this.ciStatusInterval.cancel(); - this.ciEnvironmentStatusInterval.cancel(); - }; - - MergeRequestWidget.prototype.getMergeStatus = function() { - return $.get(this.opts.merge_check_url, (data) => { - var $html = $(data); - this.updateMergeButton(this.status, this.hasCi, $html); - $('.mr-widget-body').replaceWith($html.find('.mr-widget-body')); - $('.mr-widget-footer').replaceWith($html.find('.mr-widget-footer')); - }); - }; - - MergeRequestWidget.prototype.ciLabelForStatus = function(status) { - switch (status) { - case 'success': - return 'passed'; - case 'success_with_warnings': - return 'passed with warnings'; - default: - return status; - } - }; - - MergeRequestWidget.prototype.getCIStatus = function(showNotification) { - var _this; - _this = this; - $('.ci-widget-fetching').show(); - return $.getJSON(this.opts.ci_status_url, (function(_this) { - return function(data) { - var message, status, title, callback; - _this.status = data.status; - _this.hasCi = data.has_ci; - _this.updateMergeButton(_this.status, _this.hasCi); - gl.utils.setCiStatusFavicon(_this.opts.pipeline_status_url); - if (data.environments && data.environments.length) _this.renderEnvironments(data.environments); - if (data.status !== _this.opts.ci_status || - data.sha !== _this.opts.ci_sha || - data.pipeline !== _this.opts.ci_pipeline) { - _this.opts.ci_status = data.status; - _this.showCIStatus(data.status); - if (data.coverage) { - _this.showCICoverage(data.coverage); - } - if (data.pipeline) { - _this.opts.ci_pipeline = data.pipeline; - _this.updatePipelineUrls(data.pipeline); - } - if (data.sha) { - _this.opts.ci_sha = data.sha; - _this.updateCommitUrls(data.sha); - } - if (data.status === "success" || data.status === "failed") { - callback = function() { - return _this.getMergeStatus(); - }; - return setTimeout(callback, 2000); - } - if (showNotification && data.status) { - status = _this.ciLabelForStatus(data.status); - if (status === "preparing") { - title = _this.opts.ci_title.preparing; - status = status.charAt(0).toUpperCase() + status.slice(1); - message = _this.opts.ci_message.preparing.replace('{{status}}', status); - } else { - title = _this.opts.ci_title.normal; - message = _this.opts.ci_message.normal.replace('{{status}}', status); - } - title = title.replace('{{status}}', status); - message = message.replace('{{sha}}', data.sha); - message = message.replace('{{title}}', data.title); - notify(title, message, _this.opts.gitlab_icon, function() { - this.close(); - }); - } - } - }; - })(this)); - }; - - MergeRequestWidget.prototype.getCIEnvironmentsStatus = function() { - $.getJSON(this.opts.ci_environments_status_url, (environments) => { - if (environments && environments.length) this.renderEnvironments(environments); - }); - }; - - MergeRequestWidget.prototype.renderEnvironments = function(environments) { - for (let i = 0; i < environments.length; i += 1) { - 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.stop_url) { - $('.js-stop-env-link', $template).remove(); - } - - if (environment.deployed_at && environment.deployed_at_formatted) { - environment.deployed_at = gl.utils.getTimeago().format(environment.deployed_at, 'gl_en') + '.'; - } 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) { - return; - } - $('.ci_widget').hide(); - $('.ci_widget.ci-' + state).show(); - - this.initMiniPipelineGraph(); - }; - - MergeRequestWidget.prototype.showCICoverage = function(coverage) { - var text = `Coverage ${coverage}%`; - return $('.ci_widget:visible .ci-coverage').text(text); - }; - - MergeRequestWidget.prototype.updateMergeButton = function(state, hasCi, $html) { - const allowed_states = ["failed", "canceled", "running", "pending", "success", "success_with_warnings", "skipped", "not_found"]; - let stateClass = 'btn-danger'; - if (!hasCi) { - stateClass = 'btn-create'; - } else if (indexOf.call(allowed_states, state) !== -1) { - switch (state) { - case "failed": - case "canceled": - case "not_found": - stateClass = 'btn-danger'; - break; - case "running": - stateClass = 'btn-info'; - break; - case "success": - case "success_with_warnings": - stateClass = 'btn-create'; - } - } else { - $('.ci_widget.ci-error').show(); - stateClass = 'btn-danger'; - } - - this.setMergeButtonClass(stateClass, $html); - }; - - MergeRequestWidget.prototype.setMergeButtonClass = function(css_class, $html = $('.mr-state-widget')) { - return $html.find('.js-merge-button').removeClass('btn-danger btn-info btn-create').addClass(css_class); - }; - - MergeRequestWidget.prototype.updatePipelineUrls = function(id) { - const pipelineUrl = this.opts.pipeline_path; - $('.pipeline').text(`#${id}`).attr('href', [pipelineUrl, id].join('/')); - }; - - MergeRequestWidget.prototype.updateCommitUrls = function(id) { - const commitsUrl = this.opts.commits_path; - $('.js-commit-link').text(id).attr('href', [commitsUrl, id].join('/')); - }; - - MergeRequestWidget.prototype.initMiniPipelineGraph = function() { - new MiniPipelineGraph({ - container: '.js-pipeline-inline-mr-widget-graph:visible', - }).bindEvents(); - }; - - return MergeRequestWidget; - })(); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 11e68c0a3be..9d481d7c003 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -18,12 +18,11 @@ } $els.each(function(i, dropdown) { - var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove; + var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, defaultNo, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, selectedMilestoneDefault, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove; $dropdown = $(dropdown); projectId = $dropdown.data('project-id'); milestonesUrl = $dropdown.data('milestones'); issueUpdateURL = $dropdown.data('issueUpdate'); - selectedMilestone = $dropdown.data('selected'); showNo = $dropdown.data('show-no'); showAny = $dropdown.data('show-any'); showMenuAbove = $dropdown.data('showMenuAbove'); @@ -31,6 +30,7 @@ showStarted = $dropdown.data('show-started'); useId = $dropdown.data('use-id'); defaultLabel = $dropdown.data('default-label'); + defaultNo = $dropdown.data('default-no'); issuableId = $dropdown.data('issuable-id'); abilityName = $dropdown.data('ability-name'); $selectbox = $dropdown.closest('.selectbox'); @@ -38,6 +38,9 @@ $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); $value = $block.find('.value'); $loading = $block.find('.block-loading').fadeOut(); + selectedMilestoneDefault = (showAny ? '' : null); + selectedMilestoneDefault = (showNo && defaultNo ? 'No Milestone' : selectedMilestoneDefault); + selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault; if (issueUpdateURL) { milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>'); milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; @@ -86,8 +89,18 @@ if (showMenuAbove) { $dropdown.data('glDropdown').positionMenuAbove(); } + $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active'); }); }, + renderRow: function(milestone) { + return ` + <li data-milestone-id="${milestone.name}"> + <a href='#' class='dropdown-menu-milestone-link'> + ${_.escape(milestone.title)} + </a> + </li> + `; + }, filterable: true, search: { fields: ['title'] @@ -120,15 +133,24 @@ // display:block overrides the hide-collapse rule return $value.css('display', ''); }, + opened: function(e) { + const $el = $(e.currentTarget); + if ($dropdown.hasClass('js-issue-board-sidebar')) { + selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; + } + $('a.is-active', $el).removeClass('is-active'); + $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); + }, vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: function(options) { const { $el, e } = options; let selected = options.selectedObj; - - var data, isIssueIndex, isMRIndex, page, boardsStore; + var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); + isSelecting = (selected.name !== selectedMilestone); + selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault; if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); return; @@ -142,16 +164,11 @@ boardsStore[$dropdown.data('field-name')] = selected.name; e.preventDefault(); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - if (selected.name != null) { - selectedMilestone = selected.name; - } else { - selectedMilestone = ''; - } return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); } else if ($dropdown.hasClass('js-issue-board-sidebar')) { - if (selected.id !== -1) { + if (selected.id !== -1 && isSelecting) { gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({ id: selected.id, title: selected.name diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index 36bc1257cef..5da2db063a4 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -1,12 +1,10 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, vars-on-top, one-var-declaration-per-line, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, no-param-reassign, no-cond-assign, max-len */ -/* global Api */ +import Api from './api'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - window.NamespaceSelect = (function() { function NamespaceSelect(opts) { - this.onSelectItem = bind(this.onSelectItem, this); + this.onSelectItem = this.onSelectItem.bind(this); var fieldName, showAny; this.dropdown = opts.dropdown; showAny = true; diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 4903875dfa0..114552521d4 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,13 +1,17 @@ /* eslint-disable func-names, space-before-function-paren, no-var, one-var, prefer-rest-params, max-len, vars-on-top, wrap-iife, consistent-return, comma-dangle, one-var-declaration-per-line, quotes, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, max-len, object-shorthand */ import RefSelectDropdown from '~/ref_select_dropdown'; +<<<<<<< HEAD (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 +(function() { this.NewBranchForm = (function() { function NewBranchForm(form, availableRefs) { - this.validate = bind(this.validate, this); + this.validate = this.validate.bind(this); this.branchNameError = form.find('.js-branch-name-error'); this.name = form.find('.js-branch-name'); this.ref = form.find('#ref'); @@ -54,6 +58,8 @@ import RefSelectDropdown from '~/ref_select_dropdown'; NewBranchForm.prototype.validate = function() { var errorMessage, errors, formatter, unique, validator; + const indexOf = [].indexOf; + this.branchNameError.empty(); unique = function(values, value) { if (indexOf.call(values, value) === -1) { diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index ad36f08840d..658879607e2 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -1,12 +1,10 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-return-assign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.NewCommitForm = (function() { function NewCommitForm(form, targetBranchName = 'target_branch') { this.form = form; this.targetBranchName = targetBranchName; - this.renderDestination = bind(this.renderDestination, this); + this.renderDestination = this.renderDestination.bind(this); this.targetBranchDropdown = form.find('button.js-target-branch'); this.originalBranch = form.find('.js-original-branch'); this.createMergeRequest = form.find('.js-create-merge-request'); diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 3e8240d10ec..814d2ea92b4 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -30,7 +30,7 @@ | \\s\\$(?!\\$) ) - (.+?) + ((.|\\n)+?) ( \\s\\\\end{[a-zA-Z]+}$ | @@ -45,15 +45,25 @@ let inline = false; if (typeof katex !== 'undefined') { - const katexString = text.replace(/\\/g, '\\'); - const matches = new RegExp(katexRegexString, 'gi').exec(katexString); + const katexString = text.replace(/&/g, '&') + .replace(/&=&/g, '\\space=\\space') + .replace(/<(\/?)em>/g, '_'); + const regex = new RegExp(katexRegexString, 'gi'); + const matchLocation = katexString.search(regex); + const numberOfMatches = katexString.match(regex); - if (matches && matches.length > 0) { - if (matches[1].trim() === '$' && matches[3].trim() === '$') { + if (numberOfMatches && numberOfMatches.length !== 0) { + if (matchLocation > 0) { + let matches = regex.exec(katexString); inline = true; - text = `${katexString.replace(matches[0], '')} ${katex.renderToString(matches[2])}`; + while (matches !== null) { + const renderedKatex = katex.renderToString(matches[0].replace(/\$/g, '')); + text = `${text.replace(matches[0], ` ${renderedKatex}`)}`; + matches = regex.exec(katexString); + } } else { + const matches = regex.exec(katexString); text = katex.renderToString(matches[2]); } } @@ -79,7 +89,7 @@ }, computed: { markdown() { - return marked(this.cell.source.join('')); + return marked(this.cell.source.join('').replace(/\\/g, '\\\\')); }, }, }; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 8ff25c10157..52c94096d04 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,4 +1,10 @@ -/* eslint-disable no-restricted-properties, func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase, no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line, default-case, prefer-template, consistent-return, no-alert, no-return-assign, no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, newline-per-chained-call, no-useless-escape */ +/* eslint-disable no-restricted-properties, func-names, space-before-function-paren, +no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase, +no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line, +default-case, prefer-template, consistent-return, no-alert, no-return-assign, +no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, +brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, +newline-per-chained-call, no-useless-escape */ /* global Flash */ /* global Autosave */ /* global ResolveService */ @@ -6,31 +12,35 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; +import autosize from 'vendor/autosize'; +import Dropzone from 'dropzone'; +import 'vendor/jquery.caret'; // required by jquery.atwho +import 'vendor/jquery.atwho'; +import AjaxCache from '~/lib/utils/ajax_cache'; import CommentTypeToggle from './comment_type_toggle'; +import './autosave'; +import './dropzone_input'; +import './task_list'; -require('./autosave'); -window.autosize = require('vendor/autosize'); -window.Dropzone = require('dropzone'); -require('./dropzone_input'); -require('./gfm_auto_complete'); -require('vendor/jquery.caret'); // required by jquery.atwho -require('vendor/jquery.atwho'); -require('./task_list'); +window.autosize = autosize; +window.Dropzone = Dropzone; const normalizeNewlines = function(str) { return str.replace(/\r\n/g, '\n'); }; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Notes = (function() { const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; const REGEX_SLASH_COMMANDS = /^\/\w+.*$/gm; Notes.interval = null; +<<<<<<< HEAD function Notes(notes_url, note_ids, last_fetched_at, view) { +======= + function Notes(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 this.updateTargetButtons = this.updateTargetButtons.bind(this); this.updateComment = this.updateComment.bind(this); this.visibilityChange = this.visibilityChange.bind(this); @@ -49,14 +59,19 @@ const normalizeNewlines = function(str) { this.keydownNoteText = this.keydownNoteText.bind(this); this.toggleCommitList = this.toggleCommitList.bind(this); this.postComment = this.postComment.bind(this); +<<<<<<< HEAD +======= + this.clearFlashWrapper = this.clearFlash.bind(this); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 this.notes_url = notes_url; this.note_ids = note_ids; + this.enableGFM = enableGFM; // Used to keep track of updated notes while people are editing things this.updatedNotesTrackingMap = {}; this.last_fetched_at = last_fetched_at; this.noteable_url = document.URL; - this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge")); + this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge')); this.basePollingInterval = 15000; this.maxPollingSteps = 4; @@ -85,61 +100,67 @@ const normalizeNewlines = function(str) { Notes.prototype.addBinding = function() { // Edit note link - $(document).on("click", ".js-note-edit", this.showEditForm.bind(this)); - $(document).on("click", ".note-edit-cancel", this.cancelEdit); + $(document).on('click', '.js-note-edit', this.showEditForm.bind(this)); + $(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-submit-button", this.postComment); - $(document).on("click", ".js-comment-save-button", this.updateComment); - $(document).on("keyup input", ".js-note-text", this.updateTargetButtons); + $(document).on('click', '.js-comment-submit-button', this.postComment); + $(document).on('click', '.js-comment-save-button', this.updateComment); + $(document).on('keyup input', '.js-note-text', this.updateTargetButtons); // resolve a discussion $(document).on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) - $(document).on("click", ".js-note-delete", this.removeNote); + $(document).on('click', '.js-note-delete', this.removeNote); // delete note attachment - $(document).on("click", ".js-note-attachment-delete", this.removeAttachment); + $(document).on('click', '.js-note-attachment-delete', this.removeAttachment); // reset main target form when clicking discard - $(document).on("click", ".js-note-discard", this.resetMainTargetForm); + $(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); + $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment); // reply to diff/discussion notes +<<<<<<< HEAD $(document).on("click", ".js-discussion-reply-button", this.onReplyToDiscussionNote); // add diff note $(document).on("click", ".js-add-diff-note-button", this.onAddDiffNote); +======= + $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); + // add diff note + $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 // hide diff note form - $(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm); + $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); // toggle commit list - $(document).on("click", '.system-note-commit-list-toggler', this.toggleCommitList); + $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList); // fetch notes when tab becomes visible - $(document).on("visibilitychange", this.visibilityChange); + $(document).on('visibilitychange', this.visibilityChange); // when issue status changes, we need to refresh data - $(document).on("issuable:change", this.refresh); + $(document).on('issuable:change', this.refresh); // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. - $(document).on("ajax:success", ".js-main-target-form", this.addNote); - $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); - $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); - $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); + $(document).on('ajax:success', '.js-main-target-form', this.addNote); + $(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); + $(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); + $(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); // when a key is clicked on the notes - return $(document).on("keydown", ".js-note-text", this.keydownNoteText); + return $(document).on('keydown', '.js-note-text', this.keydownNoteText); }; Notes.prototype.cleanBinding = function() { - $(document).off("click", ".js-note-edit"); - $(document).off("click", ".note-edit-cancel"); - $(document).off("click", ".js-note-delete"); - $(document).off("click", ".js-note-attachment-delete"); - $(document).off("click", ".js-discussion-reply-button"); - $(document).off("click", ".js-add-diff-note-button"); - $(document).off("visibilitychange"); - $(document).off("keyup input", ".js-note-text"); - $(document).off("click", ".js-note-target-reopen"); - $(document).off("click", ".js-note-target-close"); - $(document).off("click", ".js-note-discard"); - $(document).off("keydown", ".js-note-text"); + $(document).off('click', '.js-note-edit'); + $(document).off('click', '.note-edit-cancel'); + $(document).off('click', '.js-note-delete'); + $(document).off('click', '.js-note-attachment-delete'); + $(document).off('click', '.js-discussion-reply-button'); + $(document).off('click', '.js-add-diff-note-button'); + $(document).off('visibilitychange'); + $(document).off('keyup input', '.js-note-text'); + $(document).off('click', '.js-note-target-reopen'); + $(document).off('click', '.js-note-target-close'); + $(document).off('click', '.js-note-discard'); + $(document).off('keydown', '.js-note-text'); $(document).off('click', '.js-comment-resolve-button'); - $(document).off("click", '.system-note-commit-list-toggler'); - $(document).off("ajax:success", ".js-main-target-form"); - $(document).off("ajax:success", ".js-discussion-note-form"); - $(document).off("ajax:complete", ".js-main-target-form"); + $(document).off('click', '.system-note-commit-list-toggler'); + $(document).off('ajax:success', '.js-main-target-form'); + $(document).off('ajax:success', '.js-discussion-note-form'); + $(document).off('ajax:complete', '.js-main-target-form'); }; Notes.initCommentTypeToggle = function (form) { @@ -175,7 +196,7 @@ const normalizeNewlines = function(str) { if ($textarea.val() !== '') { return; } - myLastNote = $("li.note[data-author-id='" + gon.current_user_id + "'][data-editable]:last"); + myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, #notes')); if (myLastNote.length) { myLastNoteEditBtn = myLastNote.find('.js-note-edit'); return myLastNoteEditBtn.trigger('click', [true, myLastNote]); @@ -229,8 +250,8 @@ const normalizeNewlines = function(str) { this.refreshing = true; return $.ajax({ url: this.notes_url, - headers: { "X-Last-Fetched-At": this.last_fetched_at }, - dataType: "json", + headers: { 'X-Last-Fetched-At': this.last_fetched_at }, + dataType: 'json', success: (function(_this) { return function(data) { var notes; @@ -301,13 +322,13 @@ const normalizeNewlines = function(str) { */ Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) { - if (noteEntity.discussion_html != null) { + if (noteEntity.discussion_html) { return this.renderDiscussionNote(noteEntity, $form); } if (!noteEntity.valid) { if (noteEntity.errors.commands_only) { - new Flash(noteEntity.errors.commands_only, 'notice', this.parentTimeline); + this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline); this.refresh(); } return; @@ -317,6 +338,9 @@ const normalizeNewlines = function(str) { if (Notes.isNewNote(noteEntity, this.note_ids)) { this.note_ids.push(noteEntity.id); + if ($notesList.length) { + $notesList.find('.system-note.being-posted').remove(); + } const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList); this.setupNewNote($newNote); @@ -366,8 +390,8 @@ const normalizeNewlines = function(str) { return; } this.note_ids.push(noteEntity.id); - form = $form || $(".js-discussion-note-form[data-discussion-id='" + noteEntity.discussion_id + "']"); - row = form.closest("tr"); + form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); + row = form.closest('tr'); lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); // is this the first note of discussion? @@ -384,7 +408,7 @@ const normalizeNewlines = function(str) { row.after($discussion); } else { // Merge new discussion HTML in - var $notes = $discussion.find('.notes[data-discussion-id="' + noteEntity.discussion_id + '"]'); + var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); var contentContainerClass = '.' + $notes.closest('.notes_content') .attr('class') .split(' ') @@ -395,7 +419,7 @@ const normalizeNewlines = function(str) { } // Init discussion on 'Discussion' page if it is merge request page const page = $('body').attr('data-page'); - if ((page && page.indexOf('projects:merge_request') === 0) || !noteEntity.diff_discussion_html) { + if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) { Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); } } else { @@ -448,13 +472,13 @@ const normalizeNewlines = function(str) { Notes.prototype.resetMainTargetForm = function(e) { var form; - form = $(".js-main-target-form"); + form = $('.js-main-target-form'); // remove validation errors - form.find(".js-errors").remove(); + 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(); + form.find('.js-md-write-button').click(); + form.find('.js-note-text').val('').trigger('input'); + form.find('.js-note-text').data('autosave').reset(); var event = document.createEvent('Event'); event.initEvent('autosize:update', true, false); @@ -465,8 +489,8 @@ const normalizeNewlines = function(str) { Notes.prototype.reenableTargetFormSubmitButton = function() { var form; - form = $(".js-main-target-form"); - return form.find(".js-note-text").trigger("input"); + form = $('.js-main-target-form'); + return form.find('.js-note-text').trigger('input'); }; /* @@ -478,18 +502,18 @@ const normalizeNewlines = function(str) { Notes.prototype.setupMainTargetNoteForm = function() { var form; // find the form - form = $(".js-new-note-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").val(''); - form.find("#in_reply_to_discussion_id").remove(); + 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').val(''); + form.find('#in_reply_to_discussion_id').remove(); form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); this.parentTimeline = form.parents('.timeline'); @@ -509,21 +533,21 @@ const normalizeNewlines = function(str) { Notes.prototype.setupNoteForm = function(form) { var textarea, key; - new gl.GLForm(form); - textarea = form.find(".js-note-text"); + new gl.GLForm(form, this.enableGFM); + textarea = form.find('.js-note-text'); key = [ - "Note", - form.find("#note_noteable_type").val(), - form.find("#note_noteable_id").val(), - form.find("#note_commit_id").val(), - form.find("#note_type").val(), - form.find("#in_reply_to_discussion_id").val(), + 'Note', + form.find('#note_noteable_type').val(), + form.find('#note_noteable_id').val(), + form.find('#note_commit_id').val(), + form.find('#note_type').val(), + form.find('#in_reply_to_discussion_id').val(), // LegacyDiffNote - form.find("#note_line_code").val(), + form.find('#note_line_code').val(), // DiffNote - form.find("#note_position").val() + form.find('#note_position').val() ]; return new Autosave(textarea, key); }; @@ -538,14 +562,14 @@ const normalizeNewlines = function(str) { return this.renderNote(note); }; - Notes.prototype.addNoteError = ($form) => { + Notes.prototype.addNoteError = function($form) { let formParentTimeline; if ($form.hasClass('js-main-target-form')) { formParentTimeline = $form.parents('.timeline'); } else if ($form.hasClass('js-discussion-note-form')) { formParentTimeline = $form.closest('.discussion-notes').find('.notes'); } - return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline); + return this.addFlash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline); }; Notes.prototype.updateNoteError = $parentTimeline => new Flash('Your comment could not be updated! Please check your network connection and try again.'); @@ -668,7 +692,12 @@ const normalizeNewlines = function(str) { const $newNote = $(this.updatedNotesTrackingMap[noteId].html); $note.replaceWith($newNote); this.setupNewNote($newNote); +<<<<<<< HEAD this.updatedNotesTrackingMap[noteId] = null; +======= + // Now that we have taken care of the update, clear it out + delete this.updatedNotesTrackingMap[noteId]; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } else { $note.find('.js-finish-edit-warning').hide(); @@ -720,14 +749,14 @@ const normalizeNewlines = function(str) { lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]') .closest('.notes_holder') .prev('.line_holder'); - $(".note[id='" + noteElId + "']").each((function(_this) { + $(`.note[id="${noteElId}"]`).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. + // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, + // where $('#noteId') would return only one. return function(i, el) { var $note, $notes; $note = $(el); - $notes = $note.closest(".discussion-notes"); + $notes = $note.closest('.discussion-notes'); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -738,11 +767,11 @@ const normalizeNewlines = function(str) { $note.remove(); // check if this is the last note for this line - if ($notes.find(".note").length === 0) { - var notesTr = $notes.closest("tr"); + if ($notes.find('.note').length === 0) { + var notesTr = $notes.closest('tr'); // "Discussions" tab - $notes.closest(".timeline-entry").remove(); + $notes.closest('.timeline-entry').remove(); // The notes tr can contain multiple lists of notes, like on the parallel diff if (notesTr.find('.discussion-notes').length > 1) { @@ -766,11 +795,11 @@ const normalizeNewlines = function(str) { */ Notes.prototype.removeAttachment = function() { - const $note = $(this).closest(".note"); - $note.find(".note-attachment").remove(); - $note.find(".note-body > .note-text").show(); - $note.find(".note-header").show(); - return $note.find(".current-note-edit-form").remove(); + const $note = $(this).closest('.note'); + $note.find('.note-attachment').remove(); + $note.find('.note-body > .note-text').show(); + $note.find('.note-header').show(); + return $note.find('.current-note-edit-form').remove(); }; /* @@ -786,7 +815,11 @@ const normalizeNewlines = function(str) { Notes.prototype.replyToDiscussionNote = function(target) { var form, replyLink; form = this.cleanForm(this.formClone.clone()); +<<<<<<< HEAD replyLink = $(target).closest(".js-discussion-reply-button"); +======= + replyLink = $(target).closest('.js-discussion-reply-button'); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 // insert the form after the button replyLink .closest('.discussion-reply-holder') @@ -806,26 +839,26 @@ const normalizeNewlines = function(str) { Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) { // setup note target - var discussionID = dataHolder.data("discussionId"); + var discussionID = dataHolder.data('discussionId'); if (discussionID) { - form.attr("data-discussion-id", discussionID); - form.find("#in_reply_to_discussion_id").val(discussionID); + form.attr('data-discussion-id', discussionID); + form.find('#in_reply_to_discussion_id').val(discussionID); } - form.attr("data-line-code", dataHolder.data("lineCode")); - form.find("#line_type").val(dataHolder.data("lineType")); + form.attr('data-line-code', dataHolder.data('lineCode')); + form.find('#line_type').val(dataHolder.data('lineType')); - form.find("#note_noteable_type").val(dataHolder.data("noteableType")); - form.find("#note_noteable_id").val(dataHolder.data("noteableId")); - form.find("#note_commit_id").val(dataHolder.data("commitId")); - form.find("#note_type").val(dataHolder.data("noteType")); + form.find('#note_noteable_type').val(dataHolder.data('noteableType')); + form.find('#note_noteable_id').val(dataHolder.data('noteableId')); + form.find('#note_commit_id').val(dataHolder.data('commitId')); + form.find('#note_type').val(dataHolder.data('noteType')); // LegacyDiffNote - form.find("#note_line_code").val(dataHolder.data("lineCode")); + form.find('#note_line_code').val(dataHolder.data('lineCode')); // DiffNote - form.find("#note_position").val(dataHolder.attr("data-position")); + form.find('#note_position').val(dataHolder.attr('data-position')); 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(); @@ -834,7 +867,7 @@ const normalizeNewlines = function(str) { form .removeClass('js-main-target-form') - .addClass("discussion-form js-discussion-note-form"); + .addClass('discussion-form js-discussion-note-form'); if (typeof gl.diffNotesCompileComponents !== 'undefined') { var $commentBtn = form.find('comment-and-resolve-btn'); @@ -843,7 +876,7 @@ const normalizeNewlines = function(str) { gl.diffNotesCompileComponents(); } - form.find(".js-note-text").focus(); + form.find('.js-note-text').focus(); form .find('.js-comment-resolve-button') .attr('data-discussion-id', discussionID); @@ -876,13 +909,18 @@ const normalizeNewlines = function(str) { }) { var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; $link = $(target); +<<<<<<< HEAD row = $link.closest("tr"); +======= + row = $link.closest('tr'); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 const nextRow = row.next(); let targetRow = row; if (nextRow.is('.notes_holder')) { targetRow = nextRow; } +<<<<<<< HEAD hasNotes = targetRow.is(".notes_holder"); addForm = false; let lineTypeSelector = ''; @@ -891,6 +929,16 @@ const normalizeNewlines = function(str) { if (this.isParallelView()) { lineTypeSelector = `.${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>"; +======= + hasNotes = nextRow.is('.notes_holder'); + addForm = false; + let lineTypeSelector = ''; + 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()) { + lineTypeSelector = `.${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>'; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } const notesContentSelector = `.notes_content${lineTypeSelector} .content`; let notesContent = targetRow.find(notesContentSelector); @@ -900,12 +948,12 @@ const normalizeNewlines = function(str) { notesContent = targetRow.find(notesContentSelector); if (notesContent.length) { notesContent.show(); - replyButton = notesContent.find(".js-discussion-reply-button:visible"); + replyButton = notesContent.find('.js-discussion-reply-button:visible'); if (replyButton.length) { this.replyToDiscussionNote(replyButton[0]); } else { // In parallel view, the form may not be present in one of the panes - noteForm = notesContent.find(".js-discussion-note-form"); + noteForm = notesContent.find('.js-discussion-note-form'); if (noteForm.length === 0) { addForm = true; } @@ -943,15 +991,15 @@ const normalizeNewlines = function(str) { Notes.prototype.removeDiscussionNoteForm = function(form) { var glForm, row; - row = form.closest("tr"); + row = form.closest('tr'); glForm = form.data('gl-form'); glForm.destroy(); - form.find(".js-note-text").data("autosave").reset(); + form.find('.js-note-text').data('autosave').reset(); // show the reply button (will only work for replies) form .prev('.discussion-reply-holder') .show(); - if (row.is(".js-temp-notes-holder")) { + if (row.is('.js-temp-notes-holder')) { // remove temporary row for diff lines return row.remove(); } else { @@ -963,7 +1011,7 @@ const normalizeNewlines = function(str) { Notes.prototype.cancelDiscussionForm = function(e) { var form; e.preventDefault(); - form = $(e.target).closest(".js-discussion-note-form"); + form = $(e.target).closest('.js-discussion-note-form'); return this.removeDiscussionNoteForm(form); }; @@ -975,10 +1023,10 @@ const normalizeNewlines = function(str) { Notes.prototype.updateFormAttachment = function() { var filename, form; - form = $(this).closest("form"); + form = $(this).closest('form'); // get only the basename - filename = $(this).val().replace(/^.*[\\\/]/, ""); - return form.find(".js-attachment-filename").text(filename); + filename = $(this).val().replace(/^.*[\\\/]/, ''); + return form.find('.js-attachment-filename').text(filename); }; /* @@ -1108,6 +1156,17 @@ const normalizeNewlines = function(str) { }); }; + Notes.prototype.addFlash = function(...flashParams) { + this.flashInstance = new Flash(...flashParams); + }; + + Notes.prototype.clearFlash = function() { + if (this.flashInstance && this.flashInstance.flashContainer) { + this.flashInstance.flashContainer.hide(); + this.flashInstance = null; + } + }; + Notes.prototype.cleanForm = function($form) { // Remove JS classes that are not needed here $form @@ -1169,7 +1228,7 @@ const normalizeNewlines = function(str) { Notes.prototype.getFormData = function($form) { return { formData: $form.serialize(), - formContent: $form.find('.js-note-text').val(), + formContent: _.escape($form.find('.js-note-text').val()), formAction: $form.attr('action'), }; }; @@ -1189,19 +1248,47 @@ const normalizeNewlines = function(str) { }; /** + * Gets appropriate description from slash commands found in provided `formContent` + */ + Notes.prototype.getSlashCommandDescription = function (formContent, availableSlashCommands = []) { + let tempFormContent; + + // Identify executed slash commands from `formContent` + const executedCommands = availableSlashCommands.filter((command, index) => { + const commandRegex = new RegExp(`/${command.name}`); + return commandRegex.test(formContent); + }); + + if (executedCommands && executedCommands.length) { + if (executedCommands.length > 1) { + tempFormContent = 'Applying multiple commands'; + } else { + const commandDescription = executedCommands[0].description.toLowerCase(); + tempFormContent = `Applying command to ${commandDescription}`; + } + } else { + tempFormContent = 'Applying command'; + } + + return tempFormContent; + }; + + /** * Create placeholder note DOM element populated with comment body * that we will show while comment is being posted. * Once comment is _actually_ posted on server, we will have final element * in response that we will show in place of this temporary element. */ - Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) { + Notes.prototype.createPlaceholderNote = function ({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) { const discussionClass = isDiscussionNote ? 'discussion' : ''; const escapedFormContent = _.escape(formContent); const $tempNote = $( `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> <div class="timeline-entry-inner"> <div class="timeline-icon"> - <a href="/${currentUsername}"><span class="dummy-avatar"></span></a> + <a href="/${currentUsername}"> + <img class="avatar s40" src="${currentUserAvatar}"> + </a> </div> <div class="timeline-content ${discussionClass}"> <div class="note-header"> @@ -1226,6 +1313,23 @@ const normalizeNewlines = function(str) { }; /** + * Create Placeholder System Note DOM element populated with slash command description + */ + Notes.prototype.createPlaceholderSystemNote = function ({ formContent, uniqueId }) { + const $tempNote = $( + `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half"> + <div class="timeline-entry-inner"> + <div class="timeline-content"> + <i>${formContent}</i> + </div> + </div> + </li>` + ); + + return $tempNote; + }; + + /** * This method does following tasks step-by-step whenever a new comment * is submitted by user (both main thread comments as well as discussion comments). * @@ -1256,7 +1360,9 @@ const normalizeNewlines = function(str) { const isDiscussionForm = $form.hasClass('js-discussion-note-form'); const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); const { formData, formContent, formAction } = this.getFormData($form); - const uniqueId = _.uniqueId('tempNote_'); + let noteUniqueId; + let systemNoteUniqueId; + let hasSlashCommands = false; let $notesContainer; let tempFormContent; @@ -1277,16 +1383,28 @@ const normalizeNewlines = function(str) { tempFormContent = formContent; if (this.hasSlashCommands(formContent)) { tempFormContent = this.stripSlashCommands(formContent); + hasSlashCommands = true; } + // Show placeholder note if (tempFormContent) { - // Show placeholder note + noteUniqueId = _.uniqueId('tempNote_'); $notesContainer.append(this.createPlaceholderNote({ formContent: tempFormContent, - uniqueId, + uniqueId: noteUniqueId, isDiscussionNote, currentUsername: gon.current_username, currentUserFullname: gon.current_user_fullname, + currentUserAvatar: gon.current_user_avatar_url, + })); + } + + // Show placeholder system note + if (hasSlashCommands) { + systemNoteUniqueId = _.uniqueId('tempSystemNote_'); + $notesContainer.append(this.createPlaceholderSystemNote({ + formContent: this.getSlashCommandDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)), + uniqueId: systemNoteUniqueId, })); } @@ -1304,7 +1422,15 @@ const normalizeNewlines = function(str) { gl.utils.ajaxPost(formAction, formData) .then((note) => { // Submission successful! remove placeholder - $notesContainer.find(`#${uniqueId}`).remove(); + $notesContainer.find(`#${noteUniqueId}`).remove(); + + // Reset cached commands list when command is applied + if (hasSlashCommands) { + $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho'); + } + + // Clear previous form errors + this.clearFlashWrapper(); // Check if this was discussion comment if (isDiscussionForm) { @@ -1339,7 +1465,11 @@ const normalizeNewlines = function(str) { $form.trigger('ajax:success', [note]); }).fail(() => { // Submission failed, remove placeholder note and show Flash error message - $notesContainer.find(`#${uniqueId}`).remove(); + $notesContainer.find(`#${noteUniqueId}`).remove(); + + if (hasSlashCommands) { + $notesContainer.find(`#${systemNoteUniqueId}`).remove(); + } // Show form again on UI on failure if (isDiscussionForm && $notesContainer.length) { diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js index 5005af90d48..2ab9c4fed2c 100644 --- a/app/assets/javascripts/notifications_form.js +++ b/app/assets/javascripts/notifications_form.js @@ -1,10 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, newline-per-chained-call, comma-dangle, consistent-return, prefer-arrow-callback, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.NotificationsForm = (function() { function NotificationsForm() { - this.toggleCheckbox = bind(this.toggleCheckbox, this); + this.toggleCheckbox = this.toggleCheckbox.bind(this); this.removeEventListeners(); this.initEventListeners(); } diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index 5f6bc902cf8..01110420cca 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -1,16 +1,17 @@ -require('~/lib/utils/common_utils'); -require('~/lib/utils/url_utility'); +import '~/lib/utils/common_utils'; +import '~/lib/utils/url_utility'; (() => { const ENDLESS_SCROLL_BOTTOM_PX = 400; const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000; const Pager = { - init(limit = 0, preload = false, disable = false, callback = $.noop) { + init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) { this.url = $('.content_list').data('href') || gl.utils.removeParams(['limit', 'offset']); this.limit = limit; this.offset = parseInt(gl.utils.getParameterByName('offset'), 10) || this.limit; this.disable = disable; + this.prepareData = prepareData; this.callback = callback; this.loading = $('.loading').first(); if (preload) { @@ -29,7 +30,7 @@ require('~/lib/utils/url_utility'); dataType: 'json', error: () => this.loading.hide(), success: (data) => { - this.append(data.count, data.html); + this.append(data.count, this.prepareData(data.html)); this.callback(); // keep loading until we've filled the viewport height diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue index d1c60b570de..37a6f02d8fd 100644 --- a/app/assets/javascripts/pipelines/components/async_button.vue +++ b/app/assets/javascripts/pipelines/components/async_button.vue @@ -3,6 +3,7 @@ /* global Flash */ import '~/flash'; import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -37,6 +38,10 @@ export default { }, }, + components: { + loadingIcon, + }, + data() { return { isLoading: false, @@ -94,9 +99,6 @@ export default { <i :class="iconClass" aria-hidden="true" /> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true" - v-if="isLoading" /> + <loading-icon v-if="isLoading" /> </button> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 1f1b99ff401..df491c2872e 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,68 +1,52 @@ <script> - /* global Flash */ - import Visibility from 'visibilityjs'; - import Poll from '../../../lib/utils/poll'; - import PipelineService from '../../services/pipeline_service'; - import PipelineStore from '../../stores/pipeline_store'; import stageColumnComponent from './stage_column_component.vue'; + import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; import '../../../flash'; export default { - components: { - stageColumnComponent, + props: { + isLoading: { + type: Boolean, + required: true, + }, + pipeline: { + type: Object, + required: true, + }, }, - data() { - const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset; - const store = new PipelineStore(); - - return { - isLoading: false, - endpoint: DOMdata.endpoint, - store, - state: store.state, - }; + components: { + stageColumnComponent, + loadingIcon, }, - created() { - this.service = new PipelineService(this.endpoint); - - const poll = new Poll({ - resource: this.service, - method: 'getPipeline', - successCallback: this.successCallback, - errorCallback: this.errorCallback, - }); - - if (!Visibility.hidden()) { - this.isLoading = true; - poll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - poll.restart(); - } else { - poll.stop(); - } - }); + computed: { + graph() { + return this.pipeline.details && this.pipeline.details.stages; + }, }, methods: { - successCallback(response) { - const data = response.json(); - - this.isLoading = false; - this.store.storeGraph(data.details.stages); + capitalizeStageName(name) { + return name.charAt(0).toUpperCase() + name.slice(1); }, - errorCallback() { - this.isLoading = false; - return new Flash('An error occurred while fetching the pipeline.'); + isFirstColumn(index) { + return index === 0; }, - capitalizeStageName(name) { - return name.charAt(0).toUpperCase() + name.slice(1); + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (index === 0 && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; }, isFirstColumn(index) { @@ -89,18 +73,21 @@ <div class="build-content middle-block js-pipeline-graph"> <div class="pipeline-visualization pipeline-graph"> <div class="text-center"> - <i + <loading-icon v-if="isLoading" - class="loading-icon fa fa-spin fa-spinner fa-3x" - aria-label="Loading" - aria-hidden="true" /> + size="3" + /> </div> <ul v-if="!isLoading" class="stage-column-list"> <stage-column-component +<<<<<<< HEAD v-for="(stage, index) in state.graph" +======= + v-for="(stage, index) in graph" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 :title="capitalizeStageName(stage.name)" :jobs="stage.groups" :key="stage.name" diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue new file mode 100644 index 00000000000..4f6c5c177cf --- /dev/null +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -0,0 +1,97 @@ +<script> +import ciHeader from '../../vue_shared/components/header_ci_component.vue'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + name: 'PipelineHeaderSection', + props: { + pipeline: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + }, + components: { + ciHeader, + loadingIcon, + }, + + data() { + return { + actions: this.getActions(), + }; + }, + + computed: { + status() { + return this.pipeline.details && this.pipeline.details.status; + }, + shouldRenderContent() { + return !this.isLoading && Object.keys(this.pipeline).length; + }, + }, + + methods: { + postAction(action) { + const index = this.actions.indexOf(action); + + this.$set(this.actions[index], 'isLoading', true); + + eventHub.$emit('headerPostAction', action); + }, + + getActions() { + const actions = []; + + if (this.pipeline.retry_path) { + actions.push({ + label: 'Retry', + path: this.pipeline.retry_path, + cssClass: 'js-retry-button btn btn-inverted-secondary', + type: 'button', + isLoading: false, + }); + } + + if (this.pipeline.cancel_path) { + actions.push({ + label: 'Cancel running', + path: this.pipeline.cancel_path, + cssClass: 'js-btn-cancel-pipeline btn btn-danger', + type: 'button', + isLoading: false, + }); + } + + return actions; + }, + }, + + watch: { + pipeline() { + this.actions = this.getActions(); + }, + }, +}; +</script> +<template> + <div class="pipeline-header-container"> + <ci-header + v-if="shouldRenderContent" + :status="status" + item-name="Pipeline" + :item-id="pipeline.id" + :time="pipeline.created_at" + :user="pipeline.user" + :actions="actions" + @actionClicked="postAction" + /> + <loading-icon + v-else + size="2"/> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js deleted file mode 100644 index 4e183d5c8ec..00000000000 --- a/app/assets/javascripts/pipelines/components/pipeline_url.js +++ /dev/null @@ -1,56 +0,0 @@ -export default { - props: [ - 'pipeline', - ], - computed: { - user() { - return !!this.pipeline.user; - }, - }, - template: ` - <td> - <a - :href="pipeline.path" - class="js-pipeline-url-link"> - <span class="pipeline-id">#{{pipeline.id}}</span> - </a> - <span>by</span> - <a - class="js-pipeline-url-user" - v-if="user" - :href="pipeline.user.web_url"> - <img - v-if="user" - class="avatar has-tooltip s20 " - :title="pipeline.user.name" - data-container="body" - :src="pipeline.user.avatar_url" - > - </a> - <span - v-if="!user" - class="js-pipeline-url-api api monospace"> - API - </span> - <span - v-if="pipeline.flags.latest" - class="js-pipeline-url-lastest label label-success has-tooltip" - title="Latest pipeline for this branch" - data-original-title="Latest pipeline for this branch"> - latest - </span> - <span - v-if="pipeline.flags.yaml_errors" - class="js-pipeline-url-yaml label label-danger has-tooltip" - :title="pipeline.yaml_errors" - :data-original-title="pipeline.yaml_errors"> - yaml invalid - </span> - <span - v-if="pipeline.flags.stuck" - class="js-pipeline-url-stuck label label-warning"> - stuck - </span> - </td> - `, -}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue new file mode 100644 index 00000000000..4781a8ff1da --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -0,0 +1,65 @@ +<script> +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import tooltipMixin from '../../vue_shared/mixins/tooltip'; + +export default { + props: { + pipeline: { + type: Object, + required: true, + }, + }, + components: { + userAvatarLink, + }, + mixins: [ + tooltipMixin, + ], + computed: { + user() { + return this.pipeline.user; + }, + }, +}; +</script> +<template> + <td> + <a + :href="pipeline.path" + class="js-pipeline-url-link"> + <span class="pipeline-id">#{{pipeline.id}}</span> + </a> + <span>by</span> + <user-avatar-link + v-if="user" + class="js-pipeline-url-user" + :link-href="pipeline.user.path" + :img-src="pipeline.user.avatar_url" + :tooltip-text="pipeline.user.name" + /> + <span + v-if="!user" + class="js-pipeline-url-api api"> + API + </span> + <span + v-if="pipeline.flags.latest" + class="js-pipeline-url-lastest label label-success" + title="Latest pipeline for this branch" + ref="tooltip"> + latest + </span> + <span + v-if="pipeline.flags.yaml_errors" + class="js-pipeline-url-yaml label label-danger" + :title="pipeline.yaml_errors" + ref="tooltip"> + yaml invalid + </span> + <span + v-if="pipeline.flags.stuck" + class="js-pipeline-url-stuck label label-warning"> + stuck + </span> + </td> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.js b/app/assets/javascripts/pipelines/components/pipelines_actions.js index ffda18d2e0f..b9e066c5db1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.js +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.js @@ -3,6 +3,7 @@ import '~/flash'; import playIconSvg from 'icons/_icon_play.svg'; import eventHub from '../event_hub'; +import loadingIconComponent from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -17,6 +18,10 @@ export default { }, }, + components: { + loadingIconComponent, + }, + data() { return { playIconSvg, @@ -65,10 +70,7 @@ export default { <i class="fa fa-caret-down" aria-hidden="true" /> - <i - v-if="isLoading" - class="fa fa-spinner fa-spin" - aria-hidden="true" /> + <loading-icon v-if="isLoading" /> </button> <ul class="dropdown-menu dropdown-menu-align-right"> diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 310f44b06df..c05c76c9a64 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -15,6 +15,8 @@ /* global Flash */ import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import tooltipMixin from '../../vue_shared/mixins/tooltip'; export default { props: { @@ -30,6 +32,10 @@ export default { }, }, + mixins: [ + tooltipMixin, + ], + data() { return { isLoading: false, @@ -38,6 +44,10 @@ export default { }; }, + components: { + loadingIcon, + }, + updated() { if (this.dropdownContent.length > 0) { this.stopDropdownClickPropagation(); @@ -122,9 +132,10 @@ export default { <template> <div class="dropdown"> <button + ref="tooltip" :class="triggerButtonClass" @click="onClickStage" - class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button" + class="mini-pipeline-graph-dropdown-toggle js-builds-dropdown-button" :title="stage.title" data-placement="top" data-toggle="dropdown" @@ -153,15 +164,7 @@ export default { :class="dropdownClass" class="js-builds-dropdown-list scrollable-menu"> - <div - class="text-center" - v-if="isLoading"> - <i - class="fa fa-spin fa-spinner" - aria-hidden="true" - aria-label="Loading"> - </i> - </div> + <loading-icon v-if="isLoading"/> <ul v-else diff --git a/app/assets/javascripts/pipelines/graph_bundle.js b/app/assets/javascripts/pipelines/graph_bundle.js deleted file mode 100644 index b7a6b5d8479..00000000000 --- a/app/assets/javascripts/pipelines/graph_bundle.js +++ /dev/null @@ -1,10 +0,0 @@ -import Vue from 'vue'; -import pipelineGraph from './components/graph/graph_component.vue'; - -document.addEventListener('DOMContentLoaded', () => new Vue({ - el: '#js-pipeline-graph-vue', - components: { - pipelineGraph, - }, - render: createElement => createElement('pipeline-graph'), -})); diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js new file mode 100644 index 00000000000..bfc416da50b --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -0,0 +1,70 @@ +/* global Flash */ + +import Vue from 'vue'; +import PipelinesMediator from './pipeline_details_mediatior'; +import pipelineGraph from './components/graph/graph_component.vue'; +import pipelineHeader from './components/header_component.vue'; +import eventHub from './event_hub'; + +document.addEventListener('DOMContentLoaded', () => { + const dataset = document.querySelector('.js-pipeline-details-vue').dataset; + + const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); + + mediator.fetchPipeline(); + + // eslint-disable-next-line + new Vue({ + el: '#js-pipeline-graph-vue', + data() { + return { + mediator, + }; + }, + components: { + pipelineGraph, + }, + render(createElement) { + return createElement('pipeline-graph', { + props: { + isLoading: this.mediator.state.isLoading, + pipeline: this.mediator.store.state.pipeline, + }, + }); + }, + }); + + // eslint-disable-next-line + new Vue({ + el: '#js-pipeline-header-vue', + data() { + return { + mediator, + }; + }, + components: { + pipelineHeader, + }, + created() { + eventHub.$on('headerPostAction', this.postAction); + }, + beforeDestroy() { + eventHub.$off('headerPostAction', this.postAction); + }, + methods: { + postAction(action) { + this.mediator.service.postAction(action.path) + .then(() => this.mediator.refreshPipeline()) + .catch(() => new Flash('An error occurred while making the request.')); + }, + }, + render(createElement) { + return createElement('pipeline-header', { + props: { + isLoading: this.mediator.state.isLoading, + pipeline: this.mediator.store.state.pipeline, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js new file mode 100644 index 00000000000..82537ea06f5 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js @@ -0,0 +1,59 @@ +/* global Flash */ + +import Visibility from 'visibilityjs'; +import Poll from '../lib/utils/poll'; +import PipelineStore from './stores/pipeline_store'; +import PipelineService from './services/pipeline_service'; + +export default class pipelinesMediator { + constructor(options = {}) { + this.options = options; + this.store = new PipelineStore(); + this.service = new PipelineService(options.endpoint); + + this.state = {}; + this.state.isLoading = false; + } + + fetchPipeline() { + this.poll = new Poll({ + resource: this.service, + method: 'getPipeline', + successCallback: this.successCallback.bind(this), + errorCallback: this.errorCallback.bind(this), + }); + + if (!Visibility.hidden()) { + this.state.isLoading = true; + this.poll.makeRequest(); + } else { + this.refreshPipeline(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + } + + successCallback(response) { + const data = response.json(); + + this.state.isLoading = false; + this.store.storePipeline(data); + } + + errorCallback() { + this.state.isLoading = false; + return new Flash('An error occurred while fetching the pipeline.'); + } + + refreshPipeline() { + this.service.getPipeline() + .then(response => this.successCallback(response)) + .catch(() => this.errorCallback()); + } +} diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index 934bd7deb31..9f247af1dec 100644 --- a/app/assets/javascripts/pipelines/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -1,12 +1,13 @@ import Visibility from 'visibilityjs'; import PipelinesService from './services/pipelines_service'; import eventHub from './event_hub'; -import PipelinesTableComponent from '../vue_shared/components/pipelines_table'; -import TablePaginationComponent from '../vue_shared/components/table_pagination'; -import EmptyState from './components/empty_state.vue'; -import ErrorState from './components/error_state.vue'; -import NavigationTabs from './components/navigation_tabs'; -import NavigationControls from './components/nav_controls'; +import pipelinesTableComponent from '../vue_shared/components/pipelines_table'; +import tablePagination from '../vue_shared/components/table_pagination.vue'; +import emptyState from './components/empty_state.vue'; +import errorState from './components/error_state.vue'; +import navigationTabs from './components/navigation_tabs'; +import navigationControls from './components/nav_controls'; +import loadingIcon from '../vue_shared/components/loading_icon.vue'; import Poll from '../lib/utils/poll'; export default { @@ -18,12 +19,13 @@ export default { }, components: { - 'gl-pagination': TablePaginationComponent, - 'pipelines-table-component': PipelinesTableComponent, - 'empty-state': EmptyState, - 'error-state': ErrorState, - 'navigation-tabs': NavigationTabs, - 'navigation-controls': NavigationControls, + tablePagination, + pipelinesTableComponent, + emptyState, + errorState, + navigationTabs, + navigationControls, + loadingIcon, }, data() { @@ -50,6 +52,7 @@ export default { hasError: false, isMakingRequest: false, updateGraphDropdown: false, + hasMadeRequest: false, }; }, @@ -76,6 +79,7 @@ export default { shouldRenderEmptyState() { return !this.isLoading && !this.hasError && + this.hasMadeRequest && !this.state.pipelines.length && (this.scope === 'all' || this.scope === null); }, @@ -148,6 +152,10 @@ export default { if (!Visibility.hidden()) { this.isLoading = true; poll.makeRequest(); + } else { + // If tab is not visible we need to make the first request so we don't show the empty + // state without knowing if there are any pipelines + this.fetchPipelines(); } Visibility.change(() => { @@ -161,7 +169,7 @@ export default { eventHub.$on('refreshPipelines', this.fetchPipelines); }, - beforeDestroyed() { + beforeDestroy() { eventHub.$off('refreshPipelines'); }, @@ -200,6 +208,7 @@ export default { this.isLoading = false; this.updateGraphDropdown = true; + this.hasMadeRequest = true; }, errorCallback() { @@ -244,13 +253,11 @@ export default { <div class="content-list pipelines"> - <div - class="realtime-loading" - v-if="isLoading"> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true" /> - </div> + <loading-icon + label="Loading Pipelines" + size="3" + v-if="isLoading" + /> <empty-state v-if="shouldRenderEmptyState" @@ -275,12 +282,13 @@ export default { /> </div> - <gl-pagination + <table-pagination v-if="shouldRenderPagination" :pagenum="pagenum" :change="change" :count="state.count.all" - :pageInfo="state.pageInfo"/> + :pageInfo="state.pageInfo" + /> </div> </div> `, diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js index f1cc60c1ee0..3e0c52c7726 100644 --- a/app/assets/javascripts/pipelines/services/pipeline_service.js +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -11,4 +11,9 @@ export default class PipelineService { getPipeline() { return this.pipeline.get(); } + + // eslint-disable-next-line + postAction(endpoint) { + return Vue.http.post(`${endpoint}.json`); + } } diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index b21f84b4545..e2285494e62 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -33,8 +33,6 @@ export default class PipelinesService { /** * Post request for all pipelines actions. - * Endpoint content type needs to be: - * `Content-Type:application/x-www-form-urlencoded` * * @param {String} endpoint * @return {Promise} diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js index 86ab50d8f1e..052e34a8aef 100644 --- a/app/assets/javascripts/pipelines/stores/pipeline_store.js +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -2,10 +2,10 @@ export default class PipelineStore { constructor() { this.state = {}; - this.state.graph = []; + this.state.pipeline = {}; } - storeGraph(graph = []) { - this.state.graph = graph; + storePipeline(pipeline = {}) { + this.state.pipeline = pipeline; } } diff --git a/app/assets/javascripts/profile/profile_bundle.js b/app/assets/javascripts/profile/profile_bundle.js index 15d32825583..ff35a9bcb83 100644 --- a/app/assets/javascripts/profile/profile_bundle.js +++ b/app/assets/javascripts/profile/profile_bundle.js @@ -1,2 +1,2 @@ -require('./gl_crop'); -require('./profile'); +import './gl_crop'; +import './profile'; diff --git a/app/assets/javascripts/project_edit.js b/app/assets/javascripts/project_edit.js new file mode 100644 index 00000000000..d7d284b6c86 --- /dev/null +++ b/app/assets/javascripts/project_edit.js @@ -0,0 +1,9 @@ +export default function setupProjectEdit() { + const $transferForm = $('.js-project-transfer-form'); + const $selectNamespace = $transferForm.find('.select2'); + + $selectNamespace.on('change', () => { + $transferForm.find(':submit').prop('disabled', !$selectNamespace.val()); + }); + $selectNamespace.trigger('change'); +} diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index e01668eabef..11f9754780d 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -2,18 +2,16 @@ /* global fuzzaldrinPlus */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.ProjectFindFile = (function() { var highlighter; 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.goToBlob = this.goToBlob.bind(this); + this.goToTree = this.goToTree.bind(this); + this.selectRowDown = this.selectRowDown.bind(this); + this.selectRowUp = this.selectRowUp.bind(this); this.filePaths = {}; this.inputElement = this.element.find(".file-finder-input"); // init event diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js index e9927c1bf51..c0f757269cb 100644 --- a/app/assets/javascripts/project_new.js +++ b/app/assets/javascripts/project_new.js @@ -1,13 +1,17 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */ -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; +function highlightChanges($elm) { + $elm.addClass('highlight-changes'); + setTimeout(() => $elm.removeClass('highlight-changes'), 10); +} +(function() { this.ProjectNew = (function() { function ProjectNew() { - this.toggleSettings = bind(this.toggleSettings, this); + this.toggleSettings = this.toggleSettings.bind(this); this.$selects = $('.features select'); this.$repoSelects = this.$selects.filter('.js-repo-select'); + this.$projectSelects = this.$selects.not('.js-repo-select'); $('.project-edit-container').on('ajax:before', (function(_this) { return function() { @@ -28,6 +32,42 @@ if (!visibilityContainer) return; const visibilitySelect = new gl.VisibilitySelect(visibilityContainer); visibilitySelect.init(); + + const $visibilitySelect = $(visibilityContainer).find('select'); + let projectVisibility = $visibilitySelect.val(); + const PROJECT_VISIBILITY_PRIVATE = '0'; + + $visibilitySelect.on('change', () => { + const newProjectVisibility = $visibilitySelect.val(); + + if (projectVisibility !== newProjectVisibility) { + this.$projectSelects.each((idx, select) => { + const $select = $(select); + const $options = $select.find('option'); + const values = $.map($options, e => e.value); + + // if switched to "private", limit visibility options + if (newProjectVisibility === PROJECT_VISIBILITY_PRIVATE) { + if ($select.val() !== values[0] && $select.val() !== values[1]) { + $select.val(values[1]).trigger('change'); + highlightChanges($select); + } + $options.slice(2).disable(); + } + + // if switched from "private", increase visibility for non-disabled options + if (projectVisibility === PROJECT_VISIBILITY_PRIVATE) { + $options.enable(); + if ($select.val() !== values[0] && $select.val() !== values[values.length - 1]) { + $select.val(values[values.length - 1]).trigger('change'); + highlightChanges($select); + } + } + }); + + projectVisibility = newProjectVisibility; + } + }); }; ProjectNew.prototype.toggleSettings = function() { @@ -58,8 +98,10 @@ ProjectNew.prototype.toggleRepoVisibility = function () { var $repoAccessLevel = $('.js-repo-access-level select'); + var $lfsEnabledOption = $('.js-lfs-enabled select'); var containerRegistry = document.querySelectorAll('.js-container-registry')[0]; var containerRegistryCheckbox = document.getElementById('project_container_registry_enabled'); + var prevSelectedVal = parseInt($repoAccessLevel.val(), 10); this.$repoSelects.find("option[value='" + $repoAccessLevel.val() + "']") .nextAll() @@ -73,29 +115,40 @@ var $this = $(this); var repoSelectVal = parseInt($this.val(), 10); - $this.find('option').show(); + $this.find('option').enable(); - if (selectedVal < repoSelectVal) { - $this.val(selectedVal); + if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) { + $this.val(selectedVal).trigger('change'); + highlightChanges($this); } - $this.find("option[value='" + selectedVal + "']").nextAll().hide(); + $this.find("option[value='" + selectedVal + "']").nextAll().disable(); }); if (selectedVal) { this.$repoSelects.removeClass('disabled'); + if ($lfsEnabledOption.length) { + $lfsEnabledOption.removeClass('disabled'); + highlightChanges($lfsEnabledOption); + } if (containerRegistry) { containerRegistry.style.display = ''; } } else { this.$repoSelects.addClass('disabled'); + if ($lfsEnabledOption.length) { + $lfsEnabledOption.val('false').addClass('disabled'); + highlightChanges($lfsEnabledOption); + } if (containerRegistry) { containerRegistry.style.display = 'none'; containerRegistryCheckbox.checked = false; } } + + prevSelectedVal = selectedVal; }.bind(this)); }; diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 3c1c1e7dceb..9896b88d487 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -1,5 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-var, comma-dangle, object-shorthand, one-var, one-var-declaration-per-line, no-else-return, quotes, max-len */ -/* global Api */ +import Api from './api'; (function() { this.ProjectSelect = (function() { @@ -51,6 +51,9 @@ this.groupId = $(select).data('group-id'); this.includeGroups = $(select).data('include-groups'); this.orderBy = $(select).data('order-by') || 'id'; + this.withIssuesEnabled = $(select).data('with-issues-enabled'); + this.withMergeRequestsEnabled = $(select).data('with-merge-requests-enabled'); + placeholder = "Search for project"; if (this.includeGroups) { placeholder += " or group"; @@ -84,7 +87,11 @@ if (_this.groupId) { return Api.groupProjects(_this.groupId, query.term, projectsCallback); } else { - return Api.projects(query.term, { order_by: _this.orderBy }, projectsCallback); + return Api.projects(query.term, { + order_by: _this.orderBy, + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled + }, projectsCallback); } }; })(this), diff --git a/app/assets/javascripts/protected_branches/protected_branches_bundle.js b/app/assets/javascripts/protected_branches/protected_branches_bundle.js index 849c1e31623..874d70a1431 100644 --- a/app/assets/javascripts/protected_branches/protected_branches_bundle.js +++ b/app/assets/javascripts/protected_branches/protected_branches_bundle.js @@ -1,5 +1,5 @@ -require('./protected_branch_access_dropdown'); -require('./protected_branch_create'); -require('./protected_branch_dropdown'); -require('./protected_branch_edit'); -require('./protected_branch_edit_list'); +import './protected_branch_access_dropdown'; +import './protected_branch_create'; +import './protected_branch_dropdown'; +import './protected_branch_edit'; +import './protected_branch_edit_list'; diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js index 068e9698e1d..9d045886262 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js @@ -10,7 +10,7 @@ export default class ProtectedTagDropdown { this.$dropdown = options.$dropdown; this.$dropdownContainer = this.$dropdown.parent(); this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer'); - this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag'); + this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag'); this.buildDropdown(); this.bindEvents(); @@ -73,7 +73,7 @@ export default class ProtectedTagDropdown { }; this.$dropdownContainer - .find('.create-new-protected-tag code') + .find('.js-create-new-protected-tag code') .text(tagName); } diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js index da3fb7a6744..ae54fa5f1a9 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/raven/raven_config.js @@ -1,4 +1,5 @@ import Raven from 'raven-js'; +import $ from 'jquery'; const IGNORE_ERRORS = [ // Random plugins/extensions @@ -74,7 +75,7 @@ const RavenConfig = { }, bindRavenErrors() { - window.$(document).on('ajaxError.raven', this.handleRavenErrors); + $(document).on('ajaxError.raven', this.handleRavenErrors); }, handleRavenErrors(event, req, config, err) { diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index a9b3de281e1..b71c3097706 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -3,11 +3,9 @@ import Cookies from 'js-cookie'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Sidebar = (function() { function Sidebar(currentUser) { - this.toggleTodo = bind(this.toggleTodo, this); + this.toggleTodo = this.toggleTodo.bind(this); this.sidebar = $('aside'); this.removeListeners(); this.addEventListeners(); diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js index 15f5963353a..05caf177aec 100644 --- a/app/assets/javascripts/search.js +++ b/app/assets/javascripts/search.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, object-shorthand, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-else-return, max-len */ -/* global Api */ +/* global Flash */ +import Api from './api'; (function() { this.Search = (function() { @@ -7,6 +8,7 @@ var $groupDropdown, $projectDropdown; $groupDropdown = $('.js-search-group-dropdown'); $projectDropdown = $('.js-search-project-dropdown'); + this.groupId = $groupDropdown.data('group-id'); this.eventListeners(); $groupDropdown.glDropdown({ selectable: true, @@ -46,14 +48,18 @@ search: { fields: ['name'] }, - data: function(term, callback) { - return Api.projects(term, { order_by: 'id' }, function(data) { - data.unshift({ - name_with_namespace: 'Any' - }); - data.splice(1, 0, 'divider'); - return callback(data); - }); + data: (term, callback) => { + this.getProjectsData(term) + .then((data) => { + data.unshift({ + name_with_namespace: 'Any' + }); + data.splice(1, 0, 'divider'); + + return data; + }) + .then(data => callback(data)) + .catch(() => new Flash('Error fetching projects')); }, id: function(obj) { return obj.id; @@ -95,6 +101,18 @@ return $('.js-search-input').val('').trigger('keyup').focus(); }; + Search.prototype.getProjectsData = function(term) { + return new Promise((resolve) => { + if (this.groupId) { + Api.groupProjects(this.groupId, term, resolve); + } else { + Api.projects(term, { + order_by: 'id', + }, resolve); + } + }); + }; + return Search; })(); }).call(window); diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js new file mode 100644 index 00000000000..e67f449e1a2 --- /dev/null +++ b/app/assets/javascripts/settings_panels.js @@ -0,0 +1,27 @@ +function expandSection($section) { + $section.find('.js-settings-toggle').text('Close'); + $section.find('.settings-content').addClass('expanded').off('scroll').scrollTop(0); +} + +function closeSection($section) { + $section.find('.js-settings-toggle').text('Expand'); + $section.find('.settings-content').removeClass('expanded').on('scroll', () => expandSection($section)); +} + +function toggleSection($section) { + const $content = $section.find('.settings-content'); + $content.removeClass('no-animate'); + if ($content.hasClass('expanded')) { + closeSection($section); + } else { + expandSection($section); + } +} + +export default function initSettingsPanels() { + $('.settings').each((i, elm) => { + const $section = $(elm); + $section.on('click', '.js-settings-toggle', () => toggleSection($section)); + $section.find('.settings-content:not(.expanded)').on('scroll', () => expandSection($section)); + }); +} diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index 85659d7fa39..8ac71797c14 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -4,11 +4,9 @@ import findAndFollowLink from './shortcuts_dashboard_navigation'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Shortcuts = (function() { function Shortcuts(skipResetBindings) { - this.onToggleHelp = bind(this.onToggleHelp, this); + this.onToggleHelp = this.onToggleHelp.bind(this); this.enabledHelp = []; if (!skipResetBindings) { Mousetrap.reset(); diff --git a/app/assets/javascripts/shortcuts_blob.js b/app/assets/javascripts/shortcuts_blob.js index bfe90aef71e..ccbf7c59165 100644 --- a/app/assets/javascripts/shortcuts_blob.js +++ b/app/assets/javascripts/shortcuts_blob.js @@ -1,14 +1,14 @@ /* global Mousetrap */ /* global Shortcuts */ -require('./shortcuts'); +import './shortcuts'; const defaults = { skipResetBindings: false, fileBlobPermalinkUrl: null, }; -class ShortcutsBlob extends Shortcuts { +export default class ShortcutsBlob extends Shortcuts { constructor(opts) { const options = Object.assign({}, defaults, opts); super(options.skipResetBindings); @@ -25,5 +25,3 @@ class ShortcutsBlob extends Shortcuts { } } } - -module.exports = ShortcutsBlob; diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js index a27ac264a5c..b18b6139b35 100644 --- a/app/assets/javascripts/shortcuts_find_file.js +++ b/app/assets/javascripts/shortcuts_find_file.js @@ -2,7 +2,7 @@ /* global Mousetrap */ /* global ShortcutsNavigation */ -require('./shortcuts_navigation'); +import './shortcuts_navigation'; (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; }, diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index fe58e98cee5..51448252c0f 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -3,8 +3,8 @@ /* global ShortcutsNavigation */ /* global sidebar */ -require('mousetrap'); -require('./shortcuts_navigation'); +import 'mousetrap'; +import './shortcuts_navigation'; (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; }, @@ -38,7 +38,7 @@ require('./shortcuts_navigation'); } ShortcutsIssuable.prototype.replyWithSelectedText = function() { - var quote, documentFragment, selected, separator; + var quote, documentFragment, el, selected, separator; var replyField = $('.js-main-target-form #note_note'); documentFragment = window.gl.utils.getSelectedFragment(); @@ -47,10 +47,8 @@ require('./shortcuts_navigation'); return; } - // If the documentFragment contains more than just Markdown, don't copy as GFM. - if (documentFragment.querySelector('.md, .wiki')) return; - - selected = window.gl.CopyAsGFM.nodeToGFM(documentFragment); + el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); + selected = window.gl.CopyAsGFM.nodeToGFM(el); if (selected.trim() === "") { return; @@ -79,7 +77,9 @@ require('./shortcuts_navigation'); ShortcutsIssuable.prototype.editIssue = function() { var $editBtn; $editBtn = $('.issuable-edit'); - return gl.utils.visitUrl($editBtn.attr('href')); + // Need to click the element as on issues, editing is inline + // on merge request, editing is on a different page + $editBtn.get(0).click(); }; ShortcutsIssuable.prototype.openSidebarDropdown = function(name) { diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js index c74ab0afd0c..55bae0c08a1 100644 --- a/app/assets/javascripts/shortcuts_navigation.js +++ b/app/assets/javascripts/shortcuts_navigation.js @@ -1,9 +1,9 @@ /* eslint-disable func-names, space-before-function-paren, max-len, no-var, one-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, prefer-arrow-callback, consistent-return, no-return-assign */ /* global Mousetrap */ /* global Shortcuts */ -import findAndFollowLink from './shortcuts_dashboard_navigation'; -require('./shortcuts'); +import findAndFollowLink from './shortcuts_dashboard_navigation'; +import './shortcuts'; (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; }, diff --git a/app/assets/javascripts/shortcuts_network.js b/app/assets/javascripts/shortcuts_network.js index 4c2bf8bf001..cc44082efa9 100644 --- a/app/assets/javascripts/shortcuts_network.js +++ b/app/assets/javascripts/shortcuts_network.js @@ -2,7 +2,7 @@ /* global Mousetrap */ /* global ShortcutsNavigation */ -require('./shortcuts_navigation'); +import './shortcuts_navigation'; (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; }, diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 294d087554e..c44892dae3d 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,12 +1,10 @@ /* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - window.SingleFileDiff = (function() { var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER; - WRAPPER = '<div class="diff-content diff-wrap-lines"></div>'; + WRAPPER = '<div class="diff-content"></div>'; LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>'; @@ -16,7 +14,7 @@ function SingleFileDiff(file) { this.file = file; - this.toggleDiff = bind(this.toggleDiff, this); + this.toggleDiff = this.toggleDiff.bind(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'); diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index b1402c0a880..419c458ff34 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -1,5 +1,6 @@ /* global Flash */ -require('vendor/task_list'); + +import 'deckar01-task_list'; class TaskList { constructor(options = {}) { diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js index e62f429f1ae..9dd14488f22 100644 --- a/app/assets/javascripts/templates/issuable_template_selector.js +++ b/app/assets/javascripts/templates/issuable_template_selector.js @@ -1,5 +1,5 @@ /* eslint-disable comma-dangle, max-len, no-useless-return, no-param-reassign, max-len */ -/* global Api */ +import Api from '../api'; import TemplateSelector from '../blob/template_selector'; diff --git a/app/assets/javascripts/terminal/terminal_bundle.js b/app/assets/javascripts/terminal/terminal_bundle.js index 13cf3a10a38..134522ef961 100644 --- a/app/assets/javascripts/terminal/terminal_bundle.js +++ b/app/assets/javascripts/terminal/terminal_bundle.js @@ -1,7 +1,9 @@ -require('vendor/xterm/encoding-indexes.js'); -require('vendor/xterm/encoding.js'); -window.Terminal = require('vendor/xterm/xterm.js'); -require('vendor/xterm/fit.js'); -require('./terminal.js'); +import 'vendor/xterm/encoding-indexes'; +import 'vendor/xterm/encoding'; +import Terminal from 'vendor/xterm/xterm'; +import 'vendor/xterm/fit'; +import './terminal'; + +window.Terminal = Terminal; $(() => new gl.Terminal({ selector: '#terminal' })); diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js index 8be58023c84..7230946b484 100644 --- a/app/assets/javascripts/todos.js +++ b/app/assets/javascripts/todos.js @@ -1,5 +1,6 @@ /* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */ -/* global UsersSelect */ + +import UsersSelect from './users_select'; class Todos { constructor() { diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js index 500b78fc5d8..cd5280948fd 100644 --- a/app/assets/javascripts/u2f/authenticate.js +++ b/app/assets/javascripts/u2f/authenticate.js @@ -10,18 +10,16 @@ (function() { const global = window.gl || (window.gl = {}); - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - global.U2FAuthenticate = (function() { function U2FAuthenticate(container, form, u2fParams, fallbackButton, fallbackUI) { this.container = container; - this.renderNotSupported = bind(this.renderNotSupported, this); - this.renderAuthenticated = bind(this.renderAuthenticated, this); - this.renderError = bind(this.renderError, this); - this.renderInProgress = bind(this.renderInProgress, this); - this.renderTemplate = bind(this.renderTemplate, this); - this.authenticate = bind(this.authenticate, this); - this.start = bind(this.start, this); + this.renderNotSupported = this.renderNotSupported.bind(this); + this.renderAuthenticated = this.renderAuthenticated.bind(this); + this.renderError = this.renderError.bind(this); + this.renderInProgress = this.renderInProgress.bind(this); + this.renderTemplate = this.renderTemplate.bind(this); + this.authenticate = this.authenticate.bind(this); + this.start = this.start.bind(this); this.appId = u2fParams.app_id; this.challenge = u2fParams.challenge; this.form = form; diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js index fd1829efe18..3119b3480c3 100644 --- a/app/assets/javascripts/u2f/error.js +++ b/app/assets/javascripts/u2f/error.js @@ -2,12 +2,10 @@ /* global u2f */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.U2FError = (function() { function U2FError(errorCode, u2fFlowType) { this.errorCode = errorCode; - this.message = bind(this.message, this); + this.message = this.message.bind(this); this.httpsDisabled = window.location.protocol !== 'https:'; this.u2fFlowType = u2fFlowType; } diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js index 17631f2908d..1234d17b8fd 100644 --- a/app/assets/javascripts/u2f/register.js +++ b/app/assets/javascripts/u2f/register.js @@ -8,19 +8,17 @@ // 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); }; }; - this.U2FRegister = (function() { function U2FRegister(container, u2fParams) { this.container = container; - this.renderNotSupported = bind(this.renderNotSupported, this); - this.renderRegistered = bind(this.renderRegistered, this); - this.renderError = bind(this.renderError, this); - this.renderInProgress = bind(this.renderInProgress, this); - this.renderSetup = bind(this.renderSetup, this); - this.renderTemplate = bind(this.renderTemplate, this); - this.register = bind(this.register, this); - this.start = bind(this.start, this); + this.renderNotSupported = this.renderNotSupported.bind(this); + this.renderRegistered = this.renderRegistered.bind(this); + this.renderError = this.renderError.bind(this); + this.renderInProgress = this.renderInProgress.bind(this); + this.renderSetup = this.renderSetup.bind(this); + this.renderTemplate = this.renderTemplate.bind(this); + this.register = this.register.bind(this); + this.start = this.start.bind(this); this.appId = u2fParams.app_id; this.registerRequests = u2fParams.register_requests; this.signRequests = u2fParams.sign_requests; diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js index b9d57cbcad4..ff2208baeab 100644 --- a/app/assets/javascripts/user_callout.js +++ b/app/assets/javascripts/user_callout.js @@ -1,11 +1,10 @@ import Cookies from 'js-cookie'; -const USER_CALLOUT_COOKIE = 'user_callout_dismissed'; - export default class UserCallout { - constructor() { - this.isCalloutDismissed = Cookies.get(USER_CALLOUT_COOKIE); - this.userCalloutBody = $('.user-callout'); + constructor(className = 'user-callout') { + this.userCalloutBody = $(`.${className}`); + this.cookieName = this.userCalloutBody.data('uid'); + this.isCalloutDismissed = Cookies.get(this.cookieName); this.init(); } @@ -18,7 +17,7 @@ export default class UserCallout { dismissCallout(e) { const $currentTarget = $(e.currentTarget); - Cookies.set(USER_CALLOUT_COOKIE, 'true', { expires: 365 }); + Cookies.set(this.cookieName, 'true', { expires: 365 }); if ($currentTarget.hasClass('close')) { this.userCalloutBody.remove(); diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js index 32ffa2f0ac0..b11f691e424 100644 --- a/app/assets/javascripts/users/calendar.js +++ b/app/assets/javascripts/users/calendar.js @@ -3,12 +3,10 @@ import d3 from 'd3'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Calendar = (function() { function Calendar(timestamps, calendar_activities_path) { this.calendar_activities_path = calendar_activities_path; - this.clickDay = bind(this.clickDay, this); + this.clickDay = this.clickDay.bind(this); this.currentSelectedDate = ''; this.daySpace = 1; this.daySize = 15; diff --git a/app/assets/javascripts/users/users_bundle.js b/app/assets/javascripts/users/users_bundle.js index 580e2d84be5..a38ce4eb25e 100644 --- a/app/assets/javascripts/users/users_bundle.js +++ b/app/assets/javascripts/users/users_bundle.js @@ -1 +1 @@ -require('./calendar'); +import './calendar'; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 4b4710fafee..a17b45362cb 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */ /* global Issuable */ /* global emitSidebarEvent */ +<<<<<<< HEAD // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; @@ -21,15 +22,98 @@ window.emitSidebarEvent = window.emitSidebarEvent || $.noop; this.currentUser = currentUser; } else { this.currentUser = JSON.parse(currentUser); - } +======= + +// TODO: remove eventHub hack after code splitting refactor +window.emitSidebarEvent = window.emitSidebarEvent || $.noop; + +function UsersSelect(currentUser, els) { + var $els; + this.users = this.users.bind(this); + this.user = this.user.bind(this); + this.usersPath = "/autocomplete/users.json"; + this.userPath = "/autocomplete/users/:id.json"; + if (currentUser != null) { + if (typeof currentUser === 'object') { + this.currentUser = currentUser; + } else { + this.currentUser = JSON.parse(currentUser); + } + } + + $els = $(els); + + if (!els) { + $els = $('.js-user-search'); + } + + $els.each((function(_this) { + return function(i, dropdown) { + var options = {}; + var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove; + $dropdown = $(dropdown); + options.projectId = $dropdown.data('project-id'); + options.groupId = $dropdown.data('group-id'); + options.showCurrentUser = $dropdown.data('current-user'); + options.todoFilter = $dropdown.data('todo-filter'); + options.todoStateFilter = $dropdown.data('todo-state-filter'); + options.perPage = $dropdown.data('per-page'); + showNullUser = $dropdown.data('null-user'); + defaultNullUser = $dropdown.data('null-user-default'); + showMenuAbove = $dropdown.data('showMenuAbove'); + showAnyUser = $dropdown.data('any-user'); + firstUser = $dropdown.data('first-user'); + options.authorId = $dropdown.data('author-id'); + defaultLabel = $dropdown.data('default-label'); + issueURL = $dropdown.data('issueUpdate'); + $selectbox = $dropdown.closest('.selectbox'); + $block = $selectbox.closest('.block'); + abilityName = $dropdown.data('ability-name'); + $value = $block.find('.value'); + $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + $loading = $block.find('.block-loading').fadeOut(); + selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; + selectedId = $dropdown.data('selected'); + + if (selectedId === undefined) { + selectedId = selectedIdDefault; } - $els = $(els); + const assignYourself = function () { + const unassignedSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + } + + // Save current selected user to the DOM + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = $dropdown.data('field-name'); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + + if (currentUserInfo) { + input.value = currentUserInfo.id; + input.dataset.meta = currentUserInfo.name; + } else if (_this.currentUser) { + input.value = _this.currentUser.id; + } + + if ($selectbox) { + $dropdown.parent().before(input); + } else { + $dropdown.after(input); + } + }; - if (!els) { - $els = $('.js-user-search'); + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); } +<<<<<<< HEAD $els.each((function(_this) { return function(i, dropdown) { var options = {}; @@ -68,32 +152,164 @@ window.emitSidebarEvent = window.emitSidebarEvent || $.noop; if (unassignedSelected) { unassignedSelected.remove(); } +======= + const getSelectedUserInputs = function() { + return $selectbox + .find(`input[name="${$dropdown.data('field-name')}"]`); + }; + + const getSelected = function() { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; + + const checkMaxSelect = function() { + const maxSelect = $dropdown.data('max-select'); + if (maxSelect) { + const selected = getSelected(); + + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); + + firstSelected.remove(); + emitSidebarEvent('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } + } + }; + + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { + const selectedUsers = getSelected() + .filter(u => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter(u => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return 'Unassigned'; + } else if (selectedUsers.length === 1) { + return firstUser.name; + } else if (isSelected) { + const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); + return `${selectedUser.name} + ${otherSelected.length} more`; + } else { + return `${firstUser.name} + ${selectedUsers.length - 1} more`; + } + }; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - // Save current selected user to the DOM - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = $dropdown.data('field-name'); + $('.assign-to-me-link').on('click', (e) => { + e.preventDefault(); + $(e.currentTarget).hide(); - const currentUserInfo = $dropdown.data('currentUserInfo'); + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); - if (currentUserInfo) { - input.value = currentUserInfo.id; - input.dataset.meta = currentUserInfo.name; - } else if (_this.currentUser) { - input.value = _this.currentUser.id; - } + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); + } else { + const $input = $(`input[name="${$dropdown.data('field-name')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); + } + }); - if ($selectbox) { - $dropdown.parent().before(input); - } else { - $dropdown.after(input); - } - }; + $block.on('click', '.js-assign-yourself', (e) => { + e.preventDefault(); + return assignTo(_this.currentUser.id); + }); + + assignTo = function(selected) { + var data; + data = {}; + data[abilityName] = {}; + data[abilityName].assignee_id = selected != null ? selected : null; + $loading.removeClass('hidden').fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + + return $.ajax({ + type: 'PUT', + dataType: 'json', + url: issueURL, + data: data + }).done(function(data) { + var user; + $dropdown.trigger('loaded.gl.dropdown'); + $loading.fadeOut(); + if (data.assignee) { + user = { + name: data.assignee.name, + username: data.assignee.username, + avatar: data.assignee.avatar_url + }; + } else { + user = { + name: 'Unassigned', + username: '', + avatar: '' + }; + } + $value.html(assigneeTemplate(user)); + $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); + return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + }); + }; + 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, options, function(users) { + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }.bind(this)); + }, + processData: function(term, data, callback) { + let users = data; + + // Only show assigned user list when there is no search term + if ($dropdown.hasClass('js-multiselect') && term.length === 0) { + const selectedInputs = getSelectedUserInputs(); + + // Potential duplicate entries when dealing with issue board + // because issue board is also managed by vue + const selectedUsers = _.uniq(selectedInputs, false, a => a.value) + .filter((input) => { + const userId = parseInt(input.value, 10); + const inUsersArray = users.find(u => u.id === userId); + + return !inUsersArray && userId !== 0; + }) + .map((input) => { + const userId = parseInt(input.value, 10); + const { avatarUrl, avatar_url, name, username } = input.dataset; + return { + avatar_url: avatarUrl || avatar_url, + id: userId, + name, + username, + }; + }); - if ($block[0]) { - $block[0].addEventListener('assignYourself', assignYourself); + users = data.concat(selectedUsers); } +<<<<<<< HEAD const getSelectedUserInputs = function() { return $selectbox .find(`input[name="${$dropdown.data('field-name')}"]`); @@ -119,229 +335,189 @@ window.emitSidebarEvent = window.emitSidebarEvent || $.noop; emitSidebarEvent('sidebar.removeAssignee', { id: firstSelectedId, }); +======= + let anyUser; + let index; + let j; + let len; + let name; + let obj; + let 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 += 1)) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } } - }; - - const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { - const selectedUsers = getSelected() - .filter(u => u !== 0); - - const firstUser = getSelectedUserInputs() - .map((index, input) => ({ - name: input.dataset.meta, - value: parseInt(input.value, 10), - })) - .filter(u => u.id !== 0) - .get(0); - - if (selectedUsers.length === 0) { - return 'Unassigned'; - } else if (selectedUsers.length === 1) { - return firstUser.name; - } else if (isSelected) { - const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); - return `${selectedUser.name} + ${otherSelected.length} more`; - } else { - return `${firstUser.name} + ${selectedUsers.length - 1} more`; + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: 'Unassigned', + id: 0 + }); + } + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = 'Any User'; + } + anyUser = { + beforeDivider: true, + name: name, + id: null + }; + users.unshift(anyUser); } - }; - - $('.assign-to-me-link').on('click', (e) => { - e.preventDefault(); - $(e.currentTarget).hide(); - - if ($dropdown.data('multiSelect')) { - assignYourself(); - checkMaxSelect(); - const currentUserInfo = $dropdown.data('currentUserInfo'); - $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); - } else { - const $input = $(`input[name="${$dropdown.data('field-name')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); + if (showDivider) { + users.splice(showDivider, 0, 'divider'); } - }); - $block.on('click', '.js-assign-yourself', (e) => { - e.preventDefault(); - return assignTo(_this.currentUser.id); - }); + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); - assignTo = function(selected) { - var data; - data = {}; - data[abilityName] = {}; - data[abilityName].assignee_id = selected != null ? selected : null; - $loading.removeClass('hidden').fadeIn(); - $dropdown.trigger('loading.gl.dropdown'); - - return $.ajax({ - type: 'PUT', - dataType: 'json', - url: issueURL, - data: data - }).done(function(data) { - var user; - $dropdown.trigger('loaded.gl.dropdown'); - $loading.fadeOut(); - if (data.assignee) { - user = { - name: data.assignee.name, - username: data.assignee.username, - avatar: data.assignee.avatar_url - }; - } else { - user = { - name: 'Unassigned', - username: '', - avatar: '' - }; - } - $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); - return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); - }); - }; - 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, options, function(users) { - // GitLabDropdownFilter returns this.instance - // GitLabDropdownRemote returns this.options.instance - const glDropdown = this.instance || this.options.instance; - glDropdown.options.processData(term, users, callback); - }.bind(this)); - }, - processData: function(term, users, callback) { - let anyUser; - let index; - let j; - let len; - let name; - let obj; - let 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 += 1)) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; - } - } - } - if (showNullUser) { + if (selected.length > 0) { + if ($dropdown.data('dropdown-header')) { showDivider += 1; - users.unshift({ - beforeDivider: true, - name: 'Unassigned', - id: 0 + users.splice(showDivider, 0, { + header: $dropdown.data('dropdown-header'), }); } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - beforeDivider: true, - name: name, - id: null - }; - users.unshift(anyUser); - } - if (showDivider) { - users.splice(showDivider, 0, 'divider'); - } + const selectedUsers = users + .filter(u => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); - if ($dropdown.hasClass('js-multiselect')) { - const selected = getSelected().filter(i => i !== 0); + users = users.filter(u => selected.indexOf(u.id) === -1); - if (selected.length > 0) { - if ($dropdown.data('dropdown-header')) { - showDivider += 1; - users.splice(showDivider, 0, { - header: $dropdown.data('dropdown-header'), - }); - } + selectedUsers.forEach((selectedUser) => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); - const selectedUsers = users - .filter(u => selected.indexOf(u.id) !== -1) - .sort((a, b) => a.name > b.name); + users.splice(showDivider + 1, 0, 'divider'); + } + } + } - users = users.filter(u => selected.indexOf(u.id) === -1); + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } + }, + filterable: true, + filterRemote: true, + search: { + fields: ['name', 'username'] + }, + selectable: true, + fieldName: $dropdown.data('field-name'), + toggleLabel: function(selected, el, glDropdown) { + const inputValue = glDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = glDropdown.filteredFullData(); + const callback = glDropdown.parseData.bind(glDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } - selectedUsers.forEach((selectedUser) => { - showDivider += 1; - users.splice(showDivider, 0, selectedUser); - }); + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } - users.splice(showDivider + 1, 0, 'divider'); - } - } - } + if (selected && 'id' in selected && $(el).hasClass('is-active')) { + $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); + if (selected.text) { + return selected.text; + } else { + return selected.name; + } + } else { + $dropdown.find('.dropdown-toggle-text').addClass('is-default'); + return defaultLabel; + } + }, + defaultLabel: defaultLabel, + hidden: function(e) { + if ($dropdown.hasClass('js-multiselect')) { + emitSidebarEvent('sidebar.saveAssignees'); + } - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); - } - }, - filterable: true, - filterRemote: true, - search: { - fields: ['name', 'username'] - }, - selectable: true, - fieldName: $dropdown.data('field-name'), - toggleLabel: function(selected, el, glDropdown) { - const inputValue = glDropdown.filterInput.val(); - - if (this.multiSelect && inputValue === '') { - // Remove non-users from the fullData array - const users = glDropdown.filteredFullData(); - const callback = glDropdown.parseData.bind(glDropdown); - - // Update the data model - this.processData(inputValue, users, callback); - } + if (!$dropdown.data('always-show-selectbox')) { + $selectbox.hide(); - if (this.multiSelect) { - return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); - } + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } + }, + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('input-meta'), + clicked: function(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; + + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); + + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + checkMaxSelect(); + + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + const id = parseInt(element.value, 10); + element.remove(); + }); + emitSidebarEvent('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + emitSidebarEvent('sidebar.addAssignee', user); - if (selected && 'id' in selected && $(el).hasClass('is-active')) { - $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); - if (selected.text) { - return selected.text; - } else { - return selected.name; - } - } else { - $dropdown.find('.dropdown-toggle-text').addClass('is-default'); - return defaultLabel; + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); + + if (unassignedSelected) { + unassignedSelected.remove(); } +<<<<<<< HEAD }, defaultLabel: defaultLabel, hidden: function(e) { if ($dropdown.hasClass('js-multiselect')) { emitSidebarEvent('sidebar.saveAssignees'); +======= + } else { + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('field-name'), 0, {}); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } - if (!$dropdown.data('always-show-selectbox')) { - $selectbox.hide(); + // User unselected + emitSidebarEvent('sidebar.removeAssignee', user); + } +<<<<<<< HEAD // Recalculate where .value is because vue might have changed it $block = $selectbox.closest('.block'); $value = $block.find('.value'); @@ -374,41 +550,88 @@ window.emitSidebarEvent = window.emitSidebarEvent || $.noop; } else if (isActive) { // user selected emitSidebarEvent('sidebar.addAssignee', user); +======= + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - // Remove unassigned selection (if it was previously selected) - const unassignedSelected = $dropdown.closest('.selectbox') - .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); + 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') || $dropdown.hasClass('js-issuable-form-dropdown')) { + e.preventDefault(); - if (unassignedSelected) { - unassignedSelected.remove(); - } - } else { - if (previouslySelected.length === 0) { - // Select unassigned because there is no more selected users - this.addInput($dropdown.data('field-name'), 0, {}); - } + const isSelecting = (user.id !== selectedId); + selectedId = isSelecting ? user.id : selectedIdDefault; +<<<<<<< HEAD // User unselected emitSidebarEvent('sidebar.removeAssignee', user); } +======= + if (selectedId === gon.current_user_id) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + return; + } + if ($el.closest('.add-issues-modal').length) { + gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + return Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } else if (!$dropdown.hasClass('js-multiselect')) { + selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); + return assignTo(selected); + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + + // Automatically close dropdown after assignee is selected + // since CE has no multiple assignees + // EE does not have a max-select + if ($dropdown.data('max-select') && + getSelected().length === $dropdown.data('max-select')) { + // Close the dropdown + $dropdown.dropdown('toggle'); + } + }, + id: function (user) { + return user.id; + }, + opened: function(e) { + const $el = $(e.currentTarget); + const selected = getSelected(); + if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) { + this.addInput($dropdown.data('field-name'), 0, {}); + } + $el.find('.is-active').removeClass('is-active'); - if (getSelected().find(u => u === gon.current_user_id)) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - } - - 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') || $dropdown.hasClass('js-issuable-form-dropdown')) { - e.preventDefault(); + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } - const isSelecting = (user.id !== selectedId); - selectedId = isSelecting ? user.id : selectedIdDefault; + if (selected.length > 0) { + getSelected().forEach(selectedId => highlightSelected(selectedId)); + } else if ($dropdown.hasClass('js-issue-board-sidebar')) { + highlightSelected(0); + } else { + highlightSelected(selectedId); + } + }, + updateLabel: $dropdown.data('dropdown-title'), + renderRow: function(user) { + var avatar, img, listClosingTags, listWithName, listWithUserName, username; + username = user.username ? "@" + user.username : ""; + avatar = user.avatar_url ? user.avatar_url : false; +<<<<<<< HEAD if (selectedId === gon.current_user_id) { $('.assign-to-me-link').hide(); } else { @@ -446,11 +669,14 @@ window.emitSidebarEvent = window.emitSidebarEvent || $.noop; this.addInput($dropdown.data('field-name'), 0, {}); } $el.find('.is-active').removeClass('is-active'); +======= + let selected = false; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - function highlightSelected(id) { - $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); - } + if (this.multiSelect) { + selected = getSelected().find(u => user.id === u); +<<<<<<< HEAD if (selected.length > 0) { getSelected().forEach(selectedId => highlightSelected(selectedId)); } else if ($dropdown.hasClass('js-issue-board-sidebar')) { @@ -472,209 +698,213 @@ window.emitSidebarEvent = window.emitSidebarEvent || $.noop; const fieldName = this.fieldName; const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); +======= + const fieldName = this.fieldName; + const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); + + if (field.length) { + selected = true; + } + } else { + selected = user.id === selectedId; + } - if (field.length) { - selected = true; + img = ""; + if (user.beforeDivider != null) { + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; + } else { + if (avatar) { + img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + } + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + + return ` + <li data-user-id=${user.id}> + <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> + ${img} + <strong class='dropdown-menu-user-full-name'> + ${user.name} + </strong> + ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} + </a> + </li> + `; + } + }); + }; + })(this)); + $('.ajax-users-select').each((function(_this) { + return function(i, select) { + var firstUser, showAnyUser, showEmailUser, showNullUser; + 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'); + firstUser = $(select).data('first-user'); + return $(select).select2({ + placeholder: "Search for a user", + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + 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 += 1)) { + obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } } } else { selected = user.id === selectedId; } - - img = ""; - if (user.beforeDivider != null) { - `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; - } else { - if (avatar) { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + if (showNullUser) { + nullUser = { + name: 'Unassigned', + id: 0 + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = 'Any User'; } + anyUser = { + name: name, + id: null + }; + data.results.unshift(anyUser); } - - return ` - <li data-user-id=${user.id}> - <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> - ${img} - <strong class='dropdown-menu-user-full-name'> - ${user.name} - </strong> - ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} - </a> - </li> - `; } - }); - }; - })(this)); - $('.ajax-users-select').each((function(_this) { - return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - 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'); - firstUser = $(select).data('first-user'); - return $(select).select2({ - placeholder: "Search for a user", - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query: function(query) { - 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 += 1)) { - obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; - } - } - } - if (showNullUser) { - nullUser = { - name: 'Unassigned', - id: 0 - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - name: name, - id: null - }; - data.results.unshift(anyUser); - } - } - if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { - var trimmed = query.term.trim(); - emailUser = { - name: "Invite \"" + query.term + "\"", - username: trimmed, - id: trimmed - }; - data.results.unshift(emailUser); - } - return query.callback(data); - }); - }, - initSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - 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; + if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { + var trimmed = query.term.trim(); + emailUser = { + name: "Invite \"" + query.term + "\"", + username: trimmed, + id: trimmed + }; + data.results.unshift(emailUser); } + return query.callback(data); }); - }; - })(this)); - } - - UsersSelect.prototype.initSelection = function(element, callback) { - var id, nullUser; - id = $(element).val(); - if (id === "0") { - nullUser = { - name: 'Unassigned' - }; - return callback(nullUser); - } else if (id !== "") { - return this.user(id, callback); - } - }; - - UsersSelect.prototype.formatResult = function(user) { - var avatar; - if (user.avatar_url) { - avatar = user.avatar_url; - } else { - avatar = gon.default_avatar_url; - } - return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; - }; - - UsersSelect.prototype.formatSelection = function(user) { - return user.name; - }; - - UsersSelect.prototype.user = function(user_id, callback) { - if (!/^\d+$/.test(user_id)) { - return false; - } - - var url; - url = this.buildUrl(this.userPath); - url = url.replace(':id', user_id); - return $.ajax({ - url: url, - dataType: "json" - }).done(function(user) { - return callback(user); - }); - }; - - // 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({ - url: url, - data: { - search: query, - per_page: 20, - active: true, - project_id: options.projectId || null, - group_id: options.groupId || null, - skip_ldap: options.skipLdap || null, - todo_filter: options.todoFilter || null, - todo_state_filter: options.todoStateFilter || 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) { - return callback(users); + initSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.initSelection.apply(_this, args); + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + 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; + } }); }; - - UsersSelect.prototype.buildUrl = function(url) { - if (gon.relative_url_root != null) { - url = gon.relative_url_root.replace(/\/$/, '') + url; - } - return url; + })(this)); +} + +UsersSelect.prototype.initSelection = function(element, callback) { + var id, nullUser; + id = $(element).val(); + if (id === "0") { + nullUser = { + name: 'Unassigned' }; - - return UsersSelect; - })(); -}).call(window); + return callback(nullUser); + } else if (id !== "") { + return this.user(id, callback); + } +}; + +UsersSelect.prototype.formatResult = function(user) { + var avatar; + if (user.avatar_url) { + avatar = user.avatar_url; + } else { + avatar = gon.default_avatar_url; + } + return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; +}; + +UsersSelect.prototype.formatSelection = function(user) { + return user.name; +}; + +UsersSelect.prototype.user = function(user_id, callback) { + if (!/^\d+$/.test(user_id)) { + return false; + } + + var url; + url = this.buildUrl(this.userPath); + url = url.replace(':id', user_id); + return $.ajax({ + url: url, + dataType: "json" + }).done(function(user) { + return callback(user); + }); +}; + +// 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({ + url: url, + data: { + search: query, + per_page: options.perPage || 20, + active: true, + project_id: options.projectId || null, + group_id: options.groupId || null, + skip_ldap: options.skipLdap || null, + todo_filter: options.todoFilter || null, + todo_state_filter: options.todoStateFilter || 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) { + return callback(users); + }); +}; + +UsersSelect.prototype.buildUrl = function(url) { + if (gon.relative_url_root != null) { + url = gon.relative_url_root.replace(/\/$/, '') + url; + } + return url; +}; + +export default UsersSelect; diff --git a/app/assets/javascripts/version_check_image.js b/app/assets/javascripts/version_check_image.js index d4f716acb72..88ba991af47 100644 --- a/app/assets/javascripts/version_check_image.js +++ b/app/assets/javascripts/version_check_image.js @@ -1,4 +1,4 @@ -class VersionCheckImage { +export default class VersionCheckImage { static bindErrorEvent(imageElement) { imageElement.off('error').on('error', () => imageElement.hide()); } @@ -6,5 +6,3 @@ class VersionCheckImage { window.gl = window.gl || {}; gl.VersionCheckImage = VersionCheckImage; - -module.exports = VersionCheckImage; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js index cfd34970f11..be0b652de38 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js @@ -1,4 +1,8 @@ +<<<<<<< HEAD require('../../lib/utils/text_utility'); +======= +import '../../lib/utils/text_utility'; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 export default { name: 'MRWidgetHeader', diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js index 486b13e60af..a02b8e313cd 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js @@ -1,4 +1,9 @@ import statusCodes from '~/lib/utils/http_status'; +<<<<<<< HEAD +======= +import { bytesToMiB } from '~/lib/utils/number_utils'; + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 import MemoryGraph from '../../vue_shared/components/memory_graph'; import MRWidgetService from '../services/mr_widget_service'; @@ -9,8 +14,13 @@ export default { }, data() { return { +<<<<<<< HEAD // memoryFrom: 0, // memoryTo: 0, +======= + memoryFrom: 0, + memoryTo: 0, +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 memoryMetrics: [], deploymentTime: 0, hasMetrics: false, @@ -35,6 +45,7 @@ export default { shouldShowMetricsUnavailable() { return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed; }, +<<<<<<< HEAD }, methods: { computeGraphData(metrics, deploymentTime) { @@ -47,6 +58,40 @@ export default { // if (memory_current.length > 0) { // this.memoryTo = Number(memory_current[0].value[1]).toFixed(2); // } +======= + memoryChangeType() { + const memoryTo = Number(this.memoryTo); + const memoryFrom = Number(this.memoryFrom); + + if (memoryTo > memoryFrom) { + return 'increased'; + } else if (memoryTo < memoryFrom) { + return 'decreased'; + } + + return 'unchanged'; + }, + }, + methods: { + getMegabytes(bytesString) { + const valueInBytes = Number(bytesString).toFixed(2); + return (bytesToMiB(valueInBytes)).toFixed(2); + }, + computeGraphData(metrics, deploymentTime) { + this.loadingMetrics = false; + const { memory_before, memory_after, memory_values } = metrics; + + // Both `memory_before` and `memory_after` objects + // have peculiar structure where accessing only a specific + // index yeilds correct value that we can use to show memory delta. + if (memory_before.length > 0) { + this.memoryFrom = this.getMegabytes(memory_before[0].value[1]); + } + + if (memory_after.length > 0) { + this.memoryTo = this.getMegabytes(memory_after[0].value[1]); + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 if (memory_values.length > 0) { this.hasMetrics = true; @@ -102,7 +147,11 @@ export default { <p v-if="shouldShowMemoryGraph" class="usage-info js-usage-info"> +<<<<<<< HEAD Deployment memory usage: +======= + Memory usage <b>{{memoryChangeType}}</b> from {{memoryFrom}}MB to {{memoryTo}}MB +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 </p> <p v-if="shouldShowLoadFailure" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js index 281b74f2701..16247c80f8c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js @@ -54,6 +54,12 @@ export default { :href="mr.pipeline.path" class="pipeline-id">#{{mr.pipeline.id}}</a> {{mr.pipeline.details.status.label}} +<<<<<<< HEAD +======= + </span> + <span + v-if="mr.pipeline.details.stages.length > 0"> +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 with {{stageText}} </span> <div class="mr-widget-pipeline-graph"> @@ -70,7 +76,11 @@ export default { for <a :href="mr.pipeline.commit.commit_path" +<<<<<<< HEAD class="monospace js-commit-link"> +======= + class="commit-sha js-commit-link"> +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 {{mr.pipeline.commit.short_id}}</a>. </span> <span diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js index 8c4535f1337..f8e4aafc4a4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js @@ -1,3 +1,4 @@ +<<<<<<< HEAD export default { name: 'MRWidgetNothingToMerge', template: ` @@ -12,6 +13,47 @@ export default { There is nothing to merge from source branch into target branch. Please push new commits or use a different branch. </span> +======= +import emptyStateSVG from 'icons/_mr_widget_empty_state.svg'; + +export default { + name: 'MRWidgetNothingToMerge', + props: { + mr: { + type: Object, + required: true, + }, + }, + data() { + return { emptyStateSVG }; + }, + template: ` + <div class="mr-widget-body empty-state"> + <div class="row"> + <div class="artwork col-sm-5 col-sm-push-7 col-xs-12 text-center"> + <span v-html="emptyStateSVG"></span> + </div> + <div class="text col-sm-7 col-sm-pull-5 col-xs-12"> + <span> + Merge requests are a place to propose changes you have made to a project + and discuss those changes with others. + </span> + <p> + Interested parties can even contribute by pushing commits if they want to. + </p> + <p> + Currently there are no changes in this merge request's source branch. + Please push new commits or use a different branch. + </p> + <a + v-if="mr.newBlobPath" + :href="mr.newBlobPath" + class="btn btn-inverted btn-save"> + Create file + </a> + </div> + </div> +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 </div> `, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js index d866d4e94b0..523eaf84634 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js @@ -13,7 +13,11 @@ export default { }, data() { return { +<<<<<<< HEAD removeSourceBranch: true, +======= + removeSourceBranch: this.mr.shouldRemoveSourceBranch, +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 mergeWhenBuildSucceeds: false, useCommitMessageWithDescription: false, setToMergeWhenPipelineSucceeds: false, @@ -69,6 +73,12 @@ export default { || this.isMakingRequest || this.mr.preventMerge); }, +<<<<<<< HEAD +======= + isRemoveSourceBranchButtonDisabled() { + return this.isMergeButtonDisabled || !this.mr.canRemoveSourceBranch; + }, +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 shouldShowSquashBeforeMerge() { const { commitsCount, enableSquashBeforeMerge } = this.mr; return enableSquashBeforeMerge && commitsCount > 1; @@ -252,8 +262,14 @@ export default { <template v-if="isMergeAllowed()"> <label class="spacing"> <input +<<<<<<< HEAD v-model="removeSourceBranch" :disabled="isMergeButtonDisabled" +======= + id="remove-source-branch-input" + v-model="removeSourceBranch" + :disabled="isRemoveSourceBranchButtonDisabled" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 type="checkbox"/> Remove source branch </label> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index bfe30ee4c08..23334852758 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -41,3 +41,7 @@ export { default as getStateKey } from './stores/get_state_key'; export { default as mrWidgetOptions } from './mr_widget_options'; export { default as stateMaps } from './stores/state_maps'; export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge'; +<<<<<<< HEAD +======= +export { default as notify } from '../lib/utils/notify'; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index cd65ac069c5..7376e2ded2b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -4,6 +4,11 @@ import { } from './dependencies'; document.addEventListener('DOMContentLoaded', () => { +<<<<<<< HEAD +======= + gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 const vm = new Vue(mrWidgetOptions); window.gl.mrWidget = { diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index 99600b6664e..e346d6e53c6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -29,6 +29,10 @@ import { eventHub, stateMaps, SquashBeforeMerge, +<<<<<<< HEAD +======= + notify, +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } from './dependencies'; export default { @@ -77,8 +81,15 @@ export default { this.service.checkStatus() .then(res => res.json()) .then((res) => { +<<<<<<< HEAD this.mr.setData(res); this.setFavicon(); +======= + this.handleNotification(res); + this.mr.setData(res); + this.setFavicon(); + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 if (cb) { cb.call(null, res); } @@ -136,6 +147,18 @@ export default { new Flash('Something went wrong. Please try again.'); // eslint-disable-line }); }, +<<<<<<< HEAD +======= + handleNotification(data) { + if (data.ci_status === this.mr.ciStatus) return; + + const label = data.pipeline.details.status.label; + const title = `Pipeline ${label}`; + const message = `Pipeline ${label} for "${data.title}"`; + + notify.notifyMe(title, message, this.mr.gitlabLogo); + }, +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 resumePolling() { this.pollingInterval.resume(); }, diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 1533c857863..9d00a026cca 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -5,6 +5,11 @@ export default class MergeRequestStore { constructor(data) { this.sha = data.diff_head_sha; +<<<<<<< HEAD +======= + this.gitlabLogo = data.gitlabLogo; + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 this.setData(data); } @@ -50,13 +55,21 @@ export default class MergeRequestStore { this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path; this.removeWIPPath = data.remove_wip_path; this.sourceBranchRemoved = !data.source_branch_exists; +<<<<<<< HEAD this.shouldRemoveSourceBranch = (data.merge_params || {}).should_remove_source_branch || false; +======= + this.shouldRemoveSourceBranch = data.remove_source_branch || false; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false; this.mergePath = data.merge_path; this.statusPath = data.status_path; this.emailPatchesPath = data.email_patches_path; this.plainDiffPath = data.plain_diff_path; +<<<<<<< HEAD +======= + this.newBlobPath = data.new_blob_path; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path; this.mergeCheckPath = data.merge_check_path; this.mergeActionsContentPath = data.commit_change_content_path; diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js index ee41dc95beb..edcb337405d 100644 --- a/app/assets/javascripts/vue_shared/ci_action_icons.js +++ b/app/assets/javascripts/vue_shared/ci_action_icons.js @@ -3,7 +3,14 @@ import retrySVG from 'icons/_icon_action_retry.svg'; import playSVG from 'icons/_icon_action_play.svg'; import stopSVG from 'icons/_icon_action_stop.svg'; +/** + * For the provided action returns the respective SVG + * + * @param {String} action + * @return {SVG|String} + */ export default function getActionIcon(action) { +<<<<<<< HEAD let icon; switch (action) { case 'icon_action_cancel': @@ -21,6 +28,14 @@ export default function getActionIcon(action) { default: icon = ''; } +======= + const icons = { + icon_action_cancel: cancelSVG, + icon_action_play: playSVG, + icon_action_retry: retrySVG, + icon_action_stop: stopSVG, + }; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - return icon; + return icons[action] || ''; } diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js index fb68abd95a2..ff5ae28e062 100644 --- a/app/assets/javascripts/vue_shared/components/commit.js +++ b/app/assets/javascripts/vue_shared/components/commit.js @@ -1,4 +1,5 @@ import commitIconSvg from 'icons/_icon_commit.svg'; +import userAvatarLink from './user_avatar/user_avatar_link.vue'; export default { props: { @@ -90,7 +91,7 @@ export default { hasAuthor() { return this.author && this.author.avatar_url && - this.author.web_url && + this.author.path && this.author.username; }, @@ -110,6 +111,9 @@ export default { return { commitIconSvg }; }, + components: { + userAvatarLink, + }, template: ` <div class="branch-commit"> @@ -119,30 +123,28 @@ export default { </div> <a v-if="hasCommitRef" - class="monospace branch-name" + class="ref-name" :href="commitRef.ref_url"> {{commitRef.name}} </a> <div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div> - <a class="commit-id monospace" + <a class="commit-sha" :href="commitUrl"> {{shortSha}} </a> - <p class="commit-title"> - <span v-if="title"> - <a v-if="hasAuthor" + <div class="commit-title flex-truncate-parent"> + <span v-if="title" class="flex-truncate-child"> + <user-avatar-link + v-if="hasAuthor" class="avatar-image-container" - :href="author.web_url"> - <img - class="avatar has-tooltip s20" - :src="author.avatar_url" - :alt="userImageAltDescription" - :title="author.username" /> - </a> - + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="userImageAltDescription" + :tooltip-text="author.username" + /> <a class="commit-row-message" :href="commitUrl"> {{title}} @@ -151,7 +153,7 @@ export default { <span v-else> Cant find HEAD commit for this branch </span> - </p> + </div> </div> `, }; diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue new file mode 100644 index 00000000000..fe6d6a792e7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -0,0 +1,133 @@ +<script> +import ciIconBadge from './ci_badge_link.vue'; +import loadingIcon from './loading_icon.vue'; +import timeagoTooltip from './time_ago_tooltip.vue'; +import tooltipMixin from '../mixins/tooltip'; +import userAvatarImage from './user_avatar/user_avatar_image.vue'; + +/** + * Renders header component for job and pipeline page based on UI mockups + * + * Used in: + * - job show page + * - pipeline show page + */ +export default { + props: { + status: { + type: Object, + required: true, + }, + itemName: { + type: String, + required: true, + }, + itemId: { + type: Number, + required: true, + }, + time: { + type: String, + required: true, + }, + user: { + type: Object, + required: false, + default: () => ({}), + }, + actions: { + type: Array, + required: false, + default: () => [], + }, + }, + + mixins: [ + tooltipMixin, + ], + + components: { + ciIconBadge, + loadingIcon, + timeagoTooltip, + userAvatarImage, + }, + + computed: { + userAvatarAltText() { + return `${this.user.name}'s avatar`; + }, + }, + + methods: { + onClickAction(action) { + this.$emit('actionClicked', action); + }, + }, +}; +</script> +<template> + <header class="page-content-header"> + <section class="header-main-content"> + + <ci-icon-badge :status="status" /> + + <strong> + {{itemName}} #{{itemId}} + </strong> + + triggered + + <timeago-tooltip :time="time" /> + + by + + <template v-if="user"> + <a + :href="user.path" + :title="user.email" + class="js-user-link commit-committer-link" + ref="tooltip"> + + <user-avatar-image + :img-src="user.avatar_url" + :img-alt="userAvatarAltText" + :tooltip-text="user.name" + :img-size="24" + /> + + {{user.name}} + </a> + </template> + </section> + + <section + class="header-action-button nav-controls" + v-if="actions.length"> + <template + v-for="action in actions"> + <a + v-if="action.type === 'link'" + :href="action.path" + :class="action.cssClass"> + {{action.label}} + </a> + + <button + v-else="action.type === 'button'" + @click="onClickAction(action)" + :disabled="action.isLoading" + :class="action.cssClass" + type="button"> + {{action.label}} + + <i + v-show="action.isLoading" + class="fa fa-spin fa-spinner" + aria-hidden="true"> + </i> + </button> + </template> + </section> + </header> +</template> diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue new file mode 100644 index 00000000000..41b1d0165b0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue @@ -0,0 +1,33 @@ +<script> + export default { + props: { + label: { + type: String, + required: false, + default: 'Loading', + }, + + size: { + type: String, + required: false, + default: '1', + }, + }, + + computed: { + cssClass() { + return `fa-${this.size}x`; + }, + }, + }; +</script> +<template> + <div class="text-center"> + <i + class="fa fa-spin fa-spinner" + :class="cssClass" + aria-hidden="true" + :aria-label="label"> + </i> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue new file mode 100644 index 00000000000..e6977681e96 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -0,0 +1,107 @@ +<script> + /* global Flash */ + import markdownHeader from './header.vue'; + import markdownToolbar from './toolbar.vue'; + + export default { + props: { + markdownPreviewUrl: { + type: String, + required: false, + default: '', + }, + markdownDocs: { + type: String, + required: true, + }, + }, + data() { + return { + markdownPreview: '', + markdownPreviewLoading: false, + previewMarkdown: false, + }; + }, + components: { + markdownHeader, + markdownToolbar, + }, + methods: { + toggleMarkdownPreview() { + this.previewMarkdown = !this.previewMarkdown; + + if (!this.previewMarkdown) { + this.markdownPreview = ''; + } else { + this.markdownPreviewLoading = true; + this.$http.post( + this.markdownPreviewUrl, + { + /* + Can't use `$refs` as the component is technically in the parent component + so we access the VNode & then get the element + */ + text: this.$slots.textarea[0].elm.value, + }, + ) + .then((res) => { + const data = res.json(); + + this.markdownPreviewLoading = false; + this.markdownPreview = data.body; + + this.$nextTick(() => { + $(this.$refs['markdown-preview']).renderGFM(); + }); + }) + .catch(() => new Flash('Error loading markdown preview')); + } + }, + }, + mounted() { + /* + GLForm class handles all the toolbar buttons + */ + return new gl.GLForm($(this.$refs['gl-form']), true); + }, + }; +</script> + +<template> + <div + class="md-area prepend-top-default append-bottom-default js-vue-markdown-field" + ref="gl-form"> + <markdown-header + :preview-markdown="previewMarkdown" + @toggle-markdown="toggleMarkdownPreview" /> + <div + class="md-write-holder" + v-show="!previewMarkdown"> + <div class="zen-backdrop"> + <slot name="textarea"></slot> + <a + class="zen-control zen-control-leave js-zen-leave" + href="#" + aria-label="Enter zen mode"> + <i + class="fa fa-compress" + aria-hidden="true"> + </i> + </a> + <markdown-toolbar + :markdown-docs="markdownDocs" /> + </div> + </div> + <div + class="md md-preview-holder md-preview" + v-show="previewMarkdown"> + <div + ref="markdown-preview" + v-html="markdownPreview"> + </div> + <span v-if="markdownPreviewLoading"> + Loading... + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue new file mode 100644 index 00000000000..1a11f493b7f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -0,0 +1,113 @@ +<script> + import tooltipMixin from '../../mixins/tooltip'; + import toolbarButton from './toolbar_button.vue'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + previewMarkdown: { + type: Boolean, + required: true, + }, + }, + components: { + toolbarButton, + }, + methods: { + toggleMarkdownPreview(e, form) { + if (form && !form.find('.js-vue-markdown-field').length) { + return; + } else if (e.target.blur) { + e.target.blur(); + } + + this.$emit('toggle-markdown'); + }, + }, + mounted() { + $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview); + $(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview); + }, + beforeDestroy() { + $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview); + $(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview); + }, + }; +</script> + +<template> + <div class="md-header"> + <ul class="nav-links clearfix"> + <li :class="{ active: !previewMarkdown }"> + <a + href="#md-write-holder" + tabindex="-1" + @click.prevent="toggleMarkdownPreview($event)"> + Write + </a> + </li> + <li :class="{ active: previewMarkdown }"> + <a + href="#md-preview-holder" + tabindex="-1" + @click.prevent="toggleMarkdownPreview($event)"> + Preview + </a> + </li> + <li class="pull-right"> + <div class="toolbar-group"> + <toolbar-button + tag="**" + button-title="Add bold text" + icon="bold" /> + <toolbar-button + tag="*" + button-title="Add italic text" + icon="italic" /> + <toolbar-button + tag="> " + :prepend="true" + button-title="Insert a quote" + icon="quote-right" /> + <toolbar-button + tag="`" + tag-block="```" + button-title="Insert code" + icon="code" /> + <toolbar-button + tag="* " + :prepend="true" + button-title="Add a bullet list" + icon="list-ul" /> + <toolbar-button + tag="1. " + :prepend="true" + button-title="Add a numbered list" + icon="list-ol" /> + <toolbar-button + tag="* [ ] " + :prepend="true" + button-title="Add a task list" + icon="check-square-o" /> + </div> + <div class="toolbar-group"> + <button + aria-label="Go full screen" + class="toolbar-btn js-zen-enter" + data-container="body" + tabindex="-1" + title="Go full screen" + type="button" + ref="tooltip"> + <i + aria-hidden="true" + class="fa fa-arrows-alt fa-fw"> + </i> + </button> + </div> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue new file mode 100644 index 00000000000..93252293ba6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -0,0 +1,33 @@ +<script> + export default { + props: { + markdownDocs: { + type: String, + required: true, + }, + }, + }; +</script> + +<template> + <div class="comment-toolbar clearfix"> + <div class="toolbar-text"> + <a + :href="markdownDocs" + target="_blank" + tabindex="-1"> + Markdown is supported + </a> + </div> + <button + class="toolbar-button markdown-selector" + type="button" + tabindex="-1"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"> + </i> + Attach a file + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue new file mode 100644 index 00000000000..096be507625 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -0,0 +1,58 @@ +<script> + import tooltipMixin from '../../mixins/tooltip'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + buttonTitle: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + tag: { + type: String, + required: true, + }, + tagBlock: { + type: String, + required: false, + default: '', + }, + prepend: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + iconClass() { + return `fa-${this.icon}`; + }, + }, + }; +</script> + +<template> + <button + type="button" + class="toolbar-btn js-md hidden-xs" + tabindex="-1" + ref="tooltip" + data-container="body" + :data-md-tag="tag" + :data-md-block="tagBlock" + :data-md-prepend="prepend" + :title="buttonTitle" + :aria-label="buttonTitle"> + <i + aria-hidden="true" + class="fa fa-fw" + :class="iconClass"> + </i> + </button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js index 73e70766494..f60f8eeb43d 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -4,7 +4,7 @@ import PipelinesActionsComponent from '../../pipelines/components/pipelines_acti import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts'; import ciBadge from './ci_badge_link.vue'; import PipelinesStageComponent from '../../pipelines/components/stage.vue'; -import PipelinesUrlComponent from '../../pipelines/components/pipeline_url'; +import PipelinesUrlComponent from '../../pipelines/components/pipeline_url.vue'; import PipelinesTimeagoComponent from '../../pipelines/components/time_ago'; import CommitComponent from './commit'; @@ -62,10 +62,12 @@ export default { commitAuthor() { let commitAuthorInformation; + if (!this.pipeline || !this.pipeline.commit) { + return null; + } + // 1. person who is an author of a commit might be a GitLab user - if (this.pipeline && - this.pipeline.commit && - this.pipeline.commit.author) { + if (this.pipeline.commit.author) { // 2. if person who is an author of a commit is a GitLab user // he/she can have a GitLab avatar if (this.pipeline.commit.author.avatar_url) { @@ -77,14 +79,11 @@ export default { avatar_url: this.pipeline.commit.author_gravatar_url, }); } - } - - // 4. If committer is not a GitLab User he/she can have a Gravatar - if (this.pipeline && - this.pipeline.commit) { + // 4. If committer is not a GitLab User he/she can have a Gravatar + } else { commitAuthorInformation = { avatar_url: this.pipeline.commit.author_gravatar_url, - web_url: `mailto:${this.pipeline.commit.author_email}`, + path: `mailto:${this.pipeline.commit.author_email}`, username: this.pipeline.commit.author_name, }; } diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.js b/app/assets/javascripts/vue_shared/components/table_pagination.vue index ebb14912b00..5e7df22dd83 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.js +++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue @@ -1,3 +1,4 @@ +<script> const PAGINATION_UI_BUTTON_LIMIT = 4; const UI_LIMIT = 6; const SPREAD = '...'; @@ -114,22 +115,23 @@ export default { return items; }, }, - template: ` - <div class="gl-pagination"> - <ul class="pagination clearfix"> - <li v-for='item in getItems' - :class='{ - page: item.page, - prev: item.prev, - next: item.next, - separator: item.separator, - active: item.active, - disabled: item.disabled - }' - > - <a @click="changePage($event)">{{item.title}}</a> - </li> - </ul> - </div> - `, }; +</script> +<template> + <div class="gl-pagination"> + <ul class="pagination clearfix"> + <li + v-for="item in getItems" + :class="{ + page: item.page, + prev: item.prev, + next: item.next, + separator: item.separator, + active: item.active, + disabled: item.disabled + }"> + <a @click="changePage($event)">{{item.title}}</a> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index 934e7e8eacb..49743718187 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -1,5 +1,9 @@ <script> import tooltipMixin from '../mixins/tooltip'; +<<<<<<< HEAD +======= +import timeagoMixin from '../mixins/timeago'; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 import '../../lib/utils/datetime_utility'; /** @@ -25,13 +29,18 @@ export default { default: false, }, +<<<<<<< HEAD htmlClass: { +======= + cssClass: { +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 type: String, required: false, default: '', }, }, +<<<<<<< HEAD mixins: [tooltipMixin], computed: { @@ -62,5 +71,28 @@ export default { ref="tooltip" > {{timeFormated}} +======= + mixins: [ + tooltipMixin, + timeagoMixin, + ], + + computed: { + timeagoCssClass() { + return this.shortFormat ? 'js-short-timeago' : 'js-timeago'; + }, + }, +}; +</script> +<template> + <time + :class="[timeagoCssClass, cssClass]" + class="js-timeago js-timeago-render" + :title="tooltipTitle(time)" + :data-placement="tooltipPlacement" + data-container="body" + ref="tooltip"> + {{timeFormated(time)}} +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 </time> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue new file mode 100644 index 00000000000..cd6f8c7aee4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -0,0 +1,86 @@ +<script> + +/* This is a re-usable vue component for rendering a user avatar that + does not need to link to the user's profile. The image and an optional + tooltip can be configured by props passed to this component. + + Sample configuration: + + <user-avatar-image + :img-src="userAvatarSrc" + :img-alt="tooltipText" + :tooltip-text="tooltipText" + tooltip-placement="top" + /> + +*/ + +import defaultAvatarUrl from 'images/no_avatar.png'; +import TooltipMixin from '../../mixins/tooltip'; + +export default { + name: 'UserAvatarImage', + mixins: [TooltipMixin], + props: { + imgSrc: { + type: String, + required: false, + default: defaultAvatarUrl, + }, + cssClasses: { + type: String, + required: false, + default: '', + }, + imgAlt: { + type: String, + required: false, + default: 'user avatar', + }, + size: { + type: Number, + required: false, + default: 20, + }, + tooltipText: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + }, + computed: { + tooltipContainer() { + return this.tooltipText ? 'body' : null; + }, + avatarSizeClass() { + return `s${this.size}`; + }, + // API response sends null when gravatar is disabled and + // we provide an empty string when we use it inside user avatar link. + // In both cases we should render the defaultAvatarUrl + imageSource() { + return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + }, + }, +}; +</script> + +<template> + <img + class="avatar" + :class="[avatarSizeClass, cssClasses]" + :src="imageSource" + :width="size" + :height="size" + :alt="imgAlt" + :data-container="tooltipContainer" + :data-placement="tooltipPlacement" + :title="tooltipText" + ref="tooltip" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue new file mode 100644 index 00000000000..95898d54cf7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -0,0 +1,80 @@ +<script> + +/* This is a re-usable vue component for rendering a user avatar wrapped in + a clickable link (likely to the user's profile). The link, image, and + tooltip can be configured by props passed to this component. + + Sample configuration: + + <user-avatar-link + :link-href="userProfileUrl" + :img-src="userAvatarSrc" + :img-alt="tooltipText" + :img-size="20" + :tooltip-text="tooltipText" + tooltip-placement="top" + /> + +*/ + +import userAvatarImage from './user_avatar_image.vue'; + +export default { + name: 'UserAvatarLink', + components: { + userAvatarImage, + }, + props: { + linkHref: { + type: String, + required: false, + default: '', + }, + imgSrc: { + type: String, + required: false, + default: '', + }, + imgAlt: { + type: String, + required: false, + default: '', + }, + imgCssClasses: { + type: String, + required: false, + default: '', + }, + imgSize: { + type: Number, + required: false, + default: 20, + }, + tooltipText: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + }, +}; +</script> + +<template> + <a + class="user-avatar-link" + :href="linkHref"> + <user-avatar-image + :img-src="imgSrc" + :img-alt="imgAlt" + :css-classes="imgCssClasses" + :size="imgSize" + :tooltip-text="tooltipText" + :tooltip-placement="tooltipPlacement" + /> + </a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue new file mode 100644 index 00000000000..d2ff2ac006e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue @@ -0,0 +1,45 @@ +<script> + +/* This is a re-usable vue component for rendering a user avatar svg (typically + for a blank state). It will receive styles comparable to the user avatar, + but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported. + The svg and avatar size can be configured by props passed to this component. + + Sample configuration: + + <user-avatar-svg + :svg="potentialApproverSvg" + :size="20" + /> + +*/ + +export default { + props: { + svg: { + type: String, + required: true, + }, + size: { + type: Number, + required: false, + default: 20, + }, + }, + computed: { + avatarSizeClass() { + return `s${this.size}`; + }, + }, +}; +</script> + +<template> + <svg + :class="avatarSizeClass" + :height="size" + :width="size" + v-html="svg"> + </svg> +</template> + diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js new file mode 100644 index 00000000000..20f63ab663c --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/timeago.js @@ -0,0 +1,18 @@ +import '../../lib/utils/datetime_utility'; + +/** + * Mixin with time ago methods used in some vue components + */ +export default { + methods: { + timeFormated(time) { + const timeago = gl.utils.getTimeago(); + + return timeago.format(time); + }, + + tooltipTitle(time) { + return gl.utils.formatDate(time); + }, + }, +}; diff --git a/app/assets/javascripts/vue_shared/mixins/tooltip.js b/app/assets/javascripts/vue_shared/mixins/tooltip.js index 9bb948bff66..995c0c98505 100644 --- a/app/assets/javascripts/vue_shared/mixins/tooltip.js +++ b/app/assets/javascripts/vue_shared/mixins/tooltip.js @@ -6,4 +6,8 @@ export default { updated() { $(this.$refs.tooltip).tooltip('fixTitle'); }, + + beforeDestroy() { + $(this.$refs.tooltip).tooltip('destroy'); + }, }; diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js index d5f87588c28..740930dce5b 100644 --- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js +++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js @@ -4,7 +4,7 @@ import VueResource from 'vue-resource'; Vue.use(VueResource); // Maintain a global counter for active requests -// see: spec/support/wait_for_vue_resource.rb +// see: spec/support/wait_for_requests.rb Vue.http.interceptors.push((request, next) => { window.activeVueResources = window.activeVueResources || 0; window.activeVueResources += 1; diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js index 75fd1394a03..4194c1bc08d 100644 --- a/app/assets/javascripts/wikis.js +++ b/app/assets/javascripts/wikis.js @@ -1,8 +1,8 @@ /* eslint-disable no-param-reassign */ /* global Breakpoints */ -require('./breakpoints'); -require('vendor/jquery.nicescroll'); +import 'vendor/jquery.nicescroll'; +import './breakpoints'; ((global) => { class Wikis { diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index ce626cf7b46..b7fe552dec2 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,5 +1,4 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len */ -/* global Dropzone */ /* global Mousetrap */ // Zen Mode (full screen) textarea @@ -7,10 +6,12 @@ /*= provides zen_mode:enter */ /*= provides zen_mode:leave */ -require('vendor/jquery.scrollTo'); -window.Dropzone = require('dropzone'); -require('mousetrap'); -require('mousetrap/plugins/pause/mousetrap-pause'); +import 'vendor/jquery.scrollTo'; +import Dropzone from 'dropzone'; +import 'mousetrap'; +import 'mousetrap/plugins/pause/mousetrap-pause'; + +window.Dropzone = Dropzone; // // ### Events diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index d2ec1791d2b..8c4a8a6ff02 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -34,6 +34,7 @@ @import "framework/selects.scss"; @import "framework/sidebar.scss"; @import "framework/tables.scss"; +@import "framework/notes.scss"; @import "framework/timeline.scss"; @import "framework/typography.scss"; @import "framework/zen.scss"; @@ -48,3 +49,7 @@ @import "framework/icons.scss"; @import "framework/snippets.scss"; @import "framework/memory_graph.scss"; +<<<<<<< HEAD +======= +@import "framework/responsive-tables.scss"; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index 91c1ebd5a7d..4ae2b164d2e 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -10,6 +10,8 @@ border-radius: $avatar_radius; border: 1px solid $avatar-border; &.s16 { @include avatar-size(16px, 6px); } + &.s18 { @include avatar-size(18px, 6px); } + &.s19 { @include avatar-size(19px, 6px); } &.s20 { @include avatar-size(20px, 7px); } &.s24 { @include avatar-size(24px, 8px); } &.s26 { @include avatar-size(26px, 8px); } diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 0db3ac1a60e..75907c35b7e 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -10,7 +10,7 @@ top: 0; margin-top: 3px; padding: $gl-padding; - z-index: 9; + z-index: 300; width: 300px; font-size: 14px; background-color: $white-light; @@ -110,6 +110,7 @@ .award-control { margin: 0 5px 6px 0; outline: 0; + position: relative; &.disabled { cursor: default; @@ -227,8 +228,8 @@ .award-control-icon-positive, .award-control-icon-super-positive { position: absolute; - left: 11px; - bottom: 7px; + left: 10px; + bottom: 6px; opacity: 0; @include transition(opacity, transform); } diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index ac1fc0eb8ae..fefe5575d9b 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -23,7 +23,6 @@ .row-content-block { margin-top: 0; - margin-bottom: -$gl-padding; background-color: $gray-light; padding: $gl-padding; margin-bottom: 0; @@ -312,7 +311,7 @@ } .empty-state { - margin: 100px 0 0; + margin: 5% auto 0; .text-content { max-width: 460px; @@ -335,27 +334,12 @@ } .btn { - margin: $btn-side-margin $btn-side-margin 0 0; - } - - @media(max-width: $screen-xs-max) { - margin-top: 50px; - text-align: center; + margin: $btn-side-margin 5px; - .btn { + @media(max-width: $screen-xs-max) { width: 100%; } } - - @media(min-width: $screen-xs-max) { - &.merge-requests .text-content { - margin-top: 40px; - } - - &.labels .text-content { - margin-top: 70px; - } - } } .flex-container-block { diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 57387b913dc..00c981f64c5 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -445,3 +445,9 @@ table { word-wrap: break-word; } } + +.disabled-content { + pointer-events: none; + opacity: .5; +} + diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 5c9b71a452c..17f1dc2f479 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -97,7 +97,7 @@ .fa-chevron-down { font-size: $dropdown-chevron-size; position: relative; - top: -3px; + top: -2px; margin-left: 5px; } @@ -201,6 +201,11 @@ width: 100%; } + &.dropdown-open-left { + right: 0; + left: auto; + } + &.is-loading { .dropdown-content { display: none; @@ -261,7 +266,14 @@ text-transform: capitalize; } - .separator + .dropdown-header { + .dropdown-bold-header { + font-weight: 600; + line-height: 22px; + padding: 0 16px; + } + + .separator + .dropdown-header, + .separator + .dropdown-bold-header { padding-top: 2px; } diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index d86ae57cd9a..2d6bc17d4ff 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -1,5 +1,4 @@ gl-emoji { - display: inline-block; display: inline-flex; vertical-align: middle; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 1dd0e5ab581..d08df05fd6c 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -4,13 +4,14 @@ */ .file-holder { border: 1px solid $border-color; + border-radius: $border-radius-default; &.file-holder-no-border { border: 0; } &.readme-holder { - margin: $gl-padding-top 0; + margin: $gl-padding 0; } table { @@ -25,7 +26,7 @@ text-align: left; padding: 10px $gl-padding; word-wrap: break-word; - border-radius: 3px 3px 0 0; + border-radius: $border-radius-default $border-radius-default 0 0; &.file-title-clear { padding-left: 0; @@ -65,10 +66,10 @@ &.video { background: $file-image-bg; text-align: center; + padding: 30px; img, video { - padding: 20px; max-width: 80%; } } @@ -84,7 +85,7 @@ } /** - * Blame file + * Annotate file */ &.blame { table { @@ -94,9 +95,16 @@ tr { border-bottom: 1px solid $blame-border; + + &:last-child { + border-bottom: none; + } } td { + border-top: none; + border-bottom: none; + &:first-child { border-left: none; } @@ -107,7 +115,7 @@ } td.blame-commit { - padding: 0 10px; + padding: 5px 10px; min-width: 400px; background: $gray-light; } @@ -246,7 +254,7 @@ span.idiff { border-bottom: 1px solid $border-color; padding: 5px $gl-padding; margin: 0; - border-radius: 3px 3px 0 0; + border-radius: $border-radius-default $border-radius-default 0 0; .file-header-content { white-space: nowrap; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index e624d0d951e..cfbaaaa04c7 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -22,12 +22,6 @@ } @media (min-width: $screen-sm-min) { - .issues_bulk_update { - .dropdown-menu-toggle { - width: 132px; - } - } - .filter-item:not(:last-child) { margin-right: 6px; } @@ -90,6 +84,7 @@ .filtered-search-term { display: -webkit-flex; display: flex; + flex-shrink: 0; margin-top: 5px; margin-bottom: 5px; @@ -104,6 +99,22 @@ padding: 2px 7px; } + .name { + background-color: $filter-name-resting-color; + color: $filter-name-text-color; + border-radius: 2px 0 0 2px; + margin-right: 1px; + text-transform: capitalize; + } + + .value-container { + background-color: $white-normal; + color: $filter-value-text-color; + border-radius: 0 2px 2px 0; + margin-right: 5px; + padding-right: 8px; + } + .value { padding-right: 0; } @@ -111,7 +122,7 @@ .remove-token { display: inline-block; padding-left: 4px; - padding-right: 8px; + padding-right: 0; .fa-close { color: $gl-text-color-secondary; @@ -131,30 +142,17 @@ } } } +} +.filtered-search-token:hover, +.filtered-search-token .selected, +.filtered-search-term .selected { .name { - background-color: $filter-name-resting-color; - color: $filter-name-text-color; - border-radius: 2px 0 0 2px; - margin-right: 1px; - text-transform: capitalize; + background-color: $filter-name-selected-color; } .value-container { - background-color: $white-normal; - color: $filter-value-text-color; - border-radius: 0 2px 2px 0; - margin-right: 5px; - } - - .selected { - .name { - background-color: $filter-name-selected-color; - } - - .value-container { - background-color: $filter-value-selected-color; - } + background-color: $filter-value-selected-color; } } @@ -238,7 +236,7 @@ width: 35px; background-color: $white-light; border: none; - position: absolute; + position: static; right: 0; height: 100%; outline: none; @@ -263,7 +261,9 @@ } .filtered-search-input-dropdown-menu { + max-height: 215px; max-width: 280px; + overflow: auto; @media (max-width: $screen-xs-min) { width: auto; @@ -283,17 +283,10 @@ .filtered-search-history-dropdown-toggle-button { flex: 1; width: auto; - padding-right: 10px; - border-radius: 0; - border-top: 0; - border-left: 0; - border-bottom: 0; + border: 0; border-right: 1px solid $border-color; - color: $gl-text-color-secondary; - line-height: 1; - transition: color 0.1s linear; &:hover, @@ -301,6 +294,17 @@ color: $gl-text-color; border-color: $dropdown-input-focus-border; outline: none; + + svg { + fill: $gl-text-color; + } + } + + svg { + height: 14px; + width: 14px; + fill: $gl-text-color-secondary; + vertical-align: middle; } .dropdown-toggle-text { @@ -312,11 +316,6 @@ color: inherit; } } - - .fa { - position: static; - } - } .filtered-search-history-dropdown { @@ -373,17 +372,6 @@ padding: 0; } -.filter-dropdown { - max-height: 215px; - overflow: auto; -} - -@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - .issue-bulk-update-dropdown-toggle { - width: 100px; - } -} - @media (max-width: $screen-xs-max) { .issues-details-filters { padding: 0 0 10px; @@ -478,4 +466,5 @@ .filter-dropdown-loading { padding: 8px 16px; + text-align: center; } diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index eadb9409fee..38d884bc7eb 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -16,6 +16,22 @@ @extend .alert; @extend .alert-danger; margin: 0; + + .flash-text, + .flash-action { + display: inline-block; + } + + a.flash-action { + margin-left: 5px; + text-decoration: none; + font-weight: normal; + border-bottom: 1px solid; + + &:hover { + border-color: transparent; + } + } } .flash-notice, @@ -36,6 +52,10 @@ border-radius: 0; } } + + &:empty { + margin: 0; + } } @media (max-width: $screen-sm-max) { diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index c0de09f3968..dbdd5a4464b 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 { - font-family: $monospace_font; - font-size: 90%; + @extend .commit-sha; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 07566fd8c77..d8645afb7da 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -30,13 +30,27 @@ header { background-color: $gray-light; border: none; border-bottom: 1px solid $border-color; + position: fixed; + top: 0; + left: 0; + right: 0; @media (max-width: $screen-xs-min) { padding: 0 16px; } &.with-horizontal-nav { - border-bottom: none; + border-bottom: 0; + + .navbar-border { + height: 1px; + position: absolute; + right: 0; + left: 0; + bottom: -1px; + background-color: $border-color; + opacity: 0; + } } .container-fluid { diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 1b7d4e42258..ef864e8f6a9 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -65,3 +65,7 @@ text-decoration: none; } } + +.user-avatar-link { + text-decoration: none; +} diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 20c7bc93c28..9e8acf4e73c 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -25,6 +25,10 @@ body { .content-wrapper { padding-bottom: 100px; + + &:not(.page-with-layout-nav) { + margin-top: $header-height; + } } .container { diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index c9a25946ffd..49163653548 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -11,7 +11,6 @@ > li { padding: 10px 15px; min-height: 20px; - border-bottom: 1px solid $list-border-light; border-bottom: 1px solid $list-border; &::after { @@ -152,6 +151,7 @@ ul.content-list { margin-top: 3px; margin-bottom: 4px; + &.has-tooltip, &:last-child { margin-right: 0; diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 0140dcf19c3..600a1f53b58 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -29,10 +29,6 @@ display: none; } - .issues-holder .issue-check { - display: none; - } - .rss-btn { display: none; } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index c5460d280ce..2134250c5ce 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -45,7 +45,12 @@ li { display: flex; +<<<<<<< HEAD a { +======= + a, + .btn-link { +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 padding: $gl-btn-padding; padding-bottom: 11px; font-size: 14px; @@ -67,7 +72,29 @@ } } - &.active a { + .btn-link { + padding-top: 16px; + padding-left: 15px; + padding-right: 15px; + border-left: none; + border-right: none; + border-top: none; + border-radius: 0; + + &:hover, + &:active, + &:focus { + background-color: transparent; + } + + &:active { + outline: 0; + box-shadow: none; + } + } + + &.active a, + &.active .btn-link { border-bottom: 2px solid $link-underline-blue; color: $black; font-weight: 600; @@ -291,6 +318,7 @@ border-bottom: 1px solid $border-color; transition: padding $sidebar-transition-duration; text-align: center; + margin-top: $header-height; .container-fluid { position: relative; @@ -436,14 +464,14 @@ top: ($header-height + 1) * 3; &.affix { - top: 0; + top: $header-height; } } } } -.activities { - .nav-block { +.nav-block { + &.activities { border-bottom: 1px solid $border-color; .nav-links { diff --git a/app/assets/stylesheets/framework/notes.scss b/app/assets/stylesheets/framework/notes.scss new file mode 100644 index 00000000000..d349e3fad9c --- /dev/null +++ b/app/assets/stylesheets/framework/notes.scss @@ -0,0 +1,14 @@ +@mixin notes-media($condition, $breakpoint-width) { + @media (#{$condition}-width: ($breakpoint-width)) { + @content; + } + + // Diff is side by side + .notes_content.parallel & { + // We hide at double what we normally hide at because + // there are two columns of notes + @media (#{$condition}-width: (2 * $breakpoint-width)) { + @content; + } + } +} diff --git a/app/assets/stylesheets/framework/responsive-tables.scss b/app/assets/stylesheets/framework/responsive-tables.scss new file mode 100644 index 00000000000..f0a4c66aa1a --- /dev/null +++ b/app/assets/stylesheets/framework/responsive-tables.scss @@ -0,0 +1,91 @@ +@mixin flex-max-width($max) { + flex: 0 0 #{$max + '%'}; + max-width: #{$max + '%'}; +} + +.gl-responsive-table-row { + margin-top: 10px; + border: 1px solid $border-color; + + @media (min-width: $screen-md-min) { + padding: 15px 0; + margin: 0; + display: flex; + align-items: center; + border: none; + border-bottom: 1px solid $white-normal; + } + + .table-section { + white-space: nowrap; + + $section-widths: 10 15 20 25 30 40; + @each $width in $section-widths { + &.section-#{$width} { + flex: 0 0 #{$width + '%'}; + + @media (min-width: $screen-md-min) { + max-width: #{$width + '%'}; + } + } + } + + &:not(.table-button-footer) { + @media (max-width: $screen-sm-max) { + display: flex; + align-self: stretch; + padding: 10px; + align-items: center; + height: 62px; + + &:not(:first-of-type) { + border-top: 1px solid $white-normal; + } + } + } + } +} + +.table-row-header { + font-size: 13px; + + @media (max-width: $screen-sm-max) { + display: none; + } +} + +.table-mobile-header { + color: $gl-text-color-secondary; + @include flex-max-width(40); + + @media (min-width: $screen-md-min) { + display: none; + } +} + +.table-mobile-content { + @media (max-width: $screen-sm-max) { + @include flex-max-width(60); + text-align: right; + } +} + +.flex-truncate-parent { + display: flex; +} + +.flex-truncate-child { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + @media (min-width: $screen-md-min) { + flex: 0 0 90%; + } + + .avatar { + float: none; + margin-right: 4px; + } +} diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 9ab17e67d4c..5ae833cd5f6 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -96,7 +96,6 @@ .select2-search-field input { padding: 5px $gl-padding / 2; - font-size: 13px; height: auto; font-family: inherit; font-size: inherit; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index cf30c256395..135520e0c07 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -33,7 +33,7 @@ padding-right: 0; @media (min-width: $screen-sm-min) { - &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper { + &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper { padding-right: $gutter_collapsed_width; } @@ -56,7 +56,7 @@ z-index: 300; @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper { + &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper { padding-right: $gutter_collapsed_width; } } @@ -81,7 +81,43 @@ &.affix { position: fixed; - top: 0; + top: $header-height; + } + + &:not(.affix-top) { + min-height: 100%; + } +} + +@mixin maintain-sidebar-dimensions { + display: block; + width: $gutter-width; + padding: 10px 20px; +} + +.issues-bulk-update.right-sidebar { + @include maintain-sidebar-dimensions; + transition: right $sidebar-transition-duration; + right: -$gutter-width; + + &.right-sidebar-expanded { + @include maintain-sidebar-dimensions; + right: 0; + } + + &.right-sidebar-collapsed { + @include maintain-sidebar-dimensions; + right: -$gutter-width; + + .block { + padding: 16px 0; + width: 250px; + border-bottom: 1px solid $border-color; + } + } + + .issuable-sidebar { + padding: 0 3px; } &:not(.affix-top) { diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 70db1962228..3e18af68428 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -3,9 +3,15 @@ margin: 0; padding: 0; +<<<<<<< HEAD .note-text { p:last-child { margin-bottom: 0; +======= + &::before { + @include notes-media('max', $screen-xs-min) { + background: none; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } } @@ -26,6 +32,7 @@ border-color: $white-normal; color: $gl-text-color; border-bottom: 1px solid $border-white-light; +<<<<<<< HEAD .timeline-entry-inner { position: relative; @@ -57,11 +64,35 @@ .timeline-icon { display: none; } +======= + + .timeline-entry-inner { + position: relative; + + @include notes-media('max', $screen-xs-min) { + .timeline-icon { + display: none; + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 .timeline-content { margin-left: 0; } } + + &:target, + &.target { + background: $line-target-blue; + } + + .avatar { + margin-right: 15px; + } + + .controls { + padding-top: 10px; + float: right; + } } .discussion .timeline-entry { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 96d8a812723..785b09e622f 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -21,6 +21,10 @@ margin-top: 0; } + > :last-child { + margin-bottom: 0; + } + // Single code lines should wrap code { font-family: $monospace_font; @@ -139,6 +143,15 @@ line-height: 1.6em; overflow-x: auto; border-radius: 2px; + + + &.plain-readme { + background: none; + border: none; + padding: 0; + margin: 0; + font-size: 14px; + } } p > code { @@ -148,7 +161,7 @@ ul, ol { padding: 0; - margin: 0 0 16px !important; + margin: 0 0 16px; } ul:dir(rtl), @@ -169,14 +182,14 @@ } ul.task-list { - li.task-list-item { + > li.task-list-item { list-style-type: none; position: relative; min-height: 22px; padding-left: 28px; margin-left: 0 !important; - input.task-list-item-checkbox { + > input.task-list-item-checkbox { position: absolute; left: 8px; top: 5px; @@ -279,19 +292,6 @@ h6 { /** CODE **/ pre { font-family: $monospace_font; - - &.plain-readme { - background: none; - border: none; - padding: 0; - margin: 0; - font-size: 14px; - } -} - -.monospace { - font-family: $monospace_font; - font-size: 90%; } code { @@ -305,6 +305,24 @@ a > code { color: $link-color; } +.monospace { + font-family: $monospace_font; +} + +.commit-sha, +.ref-name { + @extend .monospace; + font-size: 95%; +} + +.git-revision-dropdown-toggle { + @extend .monospace; +} + +.git-revision-dropdown .dropdown-content ul li a { + @extend .ref-name; +} + /** * Apply Markdown typography * diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 4cfa5d718e9..4114a050d9a 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -163,7 +163,7 @@ $fixed-layout-width: 1280px; $limited-layout-width: 990px; $gl-avatar-size: 40px; $error-exclamation-point: $red-500; -$border-radius-default: 2px; +$border-radius-default: 3px; $settings-icon-size: 18px; $provider-btn-not-active-color: $blue-500; $link-underline-blue: $blue-500; @@ -187,6 +187,7 @@ $divergence-graph-bar-bg: #ccc; $divergence-graph-separator-bg: #ccc; $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; +$highlight-changes-color: rgb(235, 255, 232); /* @@ -247,7 +248,6 @@ $dark-diff-match-bg: rgba(255, 255, 255, 0.3); $dark-diff-match-color: rgba(255, 255, 255, 0.1); $file-mode-changed: #777; $file-mode-changed: #777; -$diff-image-bg: #ddd; $diff-image-info-color: grey; $diff-swipe-border: #999; $diff-view-modes-color: grey; @@ -294,7 +294,7 @@ $btn-white-active: #848484; /* * Badges */ -$badge-bg: #eee; +$badge-bg: rgba(0, 0, 0, 0.07); $badge-color: $gl-text-color-secondary; /* @@ -570,3 +570,10 @@ $filter-value-selected-color: #d7d7d7; Animation Functions */ $dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1); + +/* +Convdev Index +*/ +$color-high-score: $green-400; +$color-average-score: $orange-400; +$color-low-score: $red-400; diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss index 32eb750180f..1c1392f8f67 100644 --- a/app/assets/stylesheets/framework/wells.scss +++ b/app/assets/stylesheets/framework/wells.scss @@ -12,10 +12,14 @@ } &.branch-info { - .monospace, + .commit-sha, .commit-info { margin-left: 4px; } + + .ref-name { + font-size: 12px; + } } } diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index 09951fe3d3e..6e3829d994f 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -185,6 +185,11 @@ $dark-il: #de935f; color: $dark-highlight-color !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $dark-na; + } + .hll { background-color: $dark-hll-bg; } .c { color: $dark-c; } /* Comment */ .err { color: $dark-err; } /* Error */ diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index b6a6d298adf..68eb0c7720f 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -185,6 +185,11 @@ $monokai-gi: #a6e22e; color: $black !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $monokai-k; + } + .hll { background-color: $monokai-hll; } .c { color: $monokai-c; } /* Comment */ .err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */ diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index 4f7a50dcb4f..2cc968c32f2 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -188,6 +188,11 @@ $solarized-dark-il: #2aa198; background-color: $solarized-dark-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $solarized-dark-kd; + } + /* Solarized Dark For use with Jekyll and Pygments diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index 6463fe96c1b..b61b85a2cd1 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -196,6 +196,11 @@ $solarized-light-il: #2aa198; background-color: $solarized-light-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $solarized-light-kd; + } + /* Solarized Light For use with Jekyll and Pygments diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index ab2018bfbca..1daa10aef24 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -203,6 +203,11 @@ $white-gc-bg: #eaf2f5; background-color: $white-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $white-nb; + } + .hll { background-color: $white-hll-bg; } .c { color: $white-c; font-style: italic; } .err { color: $white-err; background-color: $white-err-bg; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 68d7ab4bf84..740e383dbb5 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -72,7 +72,9 @@ @media (min-width: $screen-sm-min) { height: 475px; // Needed for PhantomJS + // scss-lint:disable DuplicateProperty height: calc(100vh - 222px); + // scss-lint:enable DuplicateProperty min-height: 475px; transition: width .2s; @@ -94,9 +96,51 @@ @media (min-width: $screen-sm-min) { width: 400px; } + + &.is-expandable { + .board-header { + cursor: pointer; + } + } + + &.is-collapsed { + width: 50px; + + .board-header { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + .board-title { + position: initial; + padding: 0; + border-bottom: 0; + + > span { + display: block; + transform: rotate(90deg) translate(25px, 0); + } + } + + .board-title-expandable-toggle { + position: absolute; + top: 50%; + left: 50%; + margin-left: -10px; + } + + .board-list-component, + .board-issue-count-holder { + display: none; + } + } } .board-inner { + position: relative; height: 100%; font-size: $issue-boards-font-size; background: $gray-light; @@ -173,21 +217,53 @@ } } +.slide-down-enter { + transform: translateY(-100%); +} + +.slide-down-enter-active { + transition: transform $fade-in-duration; + + + .board-list { + transform: translateY(-136px); + transition: none; + } +} + +.slide-down-enter-to { + + .board-list { + transform: translateY(0); + transition: transform $fade-in-duration ease; + } +} + +.slide-down-leave { + transform: translateY(0); +} + +.slide-down-leave-active { + transition: all $fade-in-duration; + transform: translateY(-136px); + + + .board-list { + transition: transform $fade-in-duration ease; + transform: translateY(-136px); + } +} + .board-list-component { height: calc(100% - 49px); + overflow: hidden; } .board-list { height: 100%; + width: 100%; margin-bottom: 0; padding: 5px; list-style: none; overflow-y: scroll; overflow-x: hidden; - - &.is-smaller { - height: calc(100% - 136px); - } } .board-list-loading { @@ -349,6 +425,7 @@ } .board-new-issue-form { + z-index: 1; margin: 5px; } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 14a62b6cbf0..e35558ad8e8 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -29,129 +29,140 @@ } } -.build-page { - pre.trace { - background: $builds-trace-bg; - color: $white-light; - font-family: $monospace_font; - white-space: pre-wrap; - overflow: auto; - overflow-y: hidden; - font-size: 12px; - - .fa-spinner { - font-size: 24px; - margin-left: 20px; - } - } - - .environment-information { - background-color: $gray-light; - border: 1px solid $border-color; - padding: 12px $gl-padding; - border-radius: $border-radius-default; +@keyframes blinking-scroll-button { + 0% { opacity: 0.2; } + 25% { opacity: 0.5; } + 50% { opacity: 0.7; } + 100% { opacity: 1; } +} - svg { - position: relative; - top: 1px; - margin-right: 5px; - } +.build-page { + .sticky { + position: absolute; + left: 0; + right: 0; } - .truncated-info { - text-align: center; - border-bottom: 1px solid; - background-color: $black; - height: 45px; - padding: 15px; + .build-trace-container { + position: absolute; + top: 225px; + left: 15px; + bottom: 10px; + background: $black; + color: $gray-darkest; + font-family: $monospace_font; + font-size: 12px; - &.affix { - top: 0; + &.sidebar-expanded { + right: 305px; } - // with sidebar - &.affix.sidebar-expanded { - right: 312px; - left: 22px; + &.sidebar-collapsed { + right: 16px; } - // without sidebar - &.affix.sidebar-collapsed { - right: 20px; - left: 20px; + code { + background: $black; + color: $gray-darkest; } - &.affix-top { - position: absolute; + .top-bar { top: 0; - margin: 0 auto; - right: 5px; - left: 5px; - } + height: 35px; + display: flex; + justify-content: flex-end; + border-bottom: 1px outset $white-light; - .truncated-info-size { - margin: 0 5px; - } + .truncated-info { + margin: 0 auto; + align-self: center; - .raw-link { - color: inherit; - margin-left: 5px; - text-decoration: underline; + .truncated-info-size { + margin: 0 5px; + } + + .raw-link { + color: inherit; + margin-left: 5px; + text-decoration: underline; + } + } } - } -} -.scroll-controls { - height: 100%; + .controllers { + display: flex; + align-self: center; + font-size: 15px; - .scroll-step { - width: 31px; - margin: 0 0 0 auto; - } + svg { + height: 15px; + display: block; + fill: $white-light; + } - .scroll-link, - .autoscroll-container { - right: 25px; - z-index: 1; - } + a, + .btn-scroll { + margin: 0 8px; + color: $white-light; + } - .scroll-link { - position: fixed; - display: block; - margin-bottom: 10px; + .btn-scroll.animate { + .first-triangle { + animation: blinking-scroll-button 1s ease infinite; + animation-delay: .3s; + } - &.scroll-top .gitlab-icon-scroll-up-hover, - &.scroll-top:hover .gitlab-icon-scroll-up, - &.scroll-bottom .gitlab-icon-scroll-down-hover, - &.scroll-bottom:hover .gitlab-icon-scroll-down { - display: none; - } + .second-triangle { + animation: blinking-scroll-button 1s ease infinite; + animation-delay: .2s; + } - &.scroll-top:hover .gitlab-icon-scroll-up-hover, - &.scroll-bottom:hover .gitlab-icon-scroll-down-hover { - display: inline-block; - } + .third-triangle { + animation: blinking-scroll-button 1s ease infinite; + } - &.scroll-top { - top: 10px; - } + &:disabled { + opacity: 1; + } + } - &.scroll-bottom { - bottom: -2px; + .btn-scroll:disabled { + opacity: 0.35; + cursor: not-allowed; + } } } - .autoscroll-container { - position: absolute; + .bash { + top: 35px; + left: 10px; + bottom: 0; + overflow-y: hidden; + padding-bottom: 20px; + padding-right: 20px; } - &.sidebar-expanded { + .environment-information { + background-color: $gray-light; + border: 1px solid $border-color; + padding: 12px $gl-padding; + border-radius: $border-radius-default; - .scroll-link, - .autoscroll-container { - right: ($gutter_width + ($gl-padding * 2)); + svg { + position: relative; + top: 1px; + margin-right: 5px; } } + + .build-loader-animation { + position: relative; + width: 6px; + height: 6px; + margin: auto auto 12px 2px; + border-radius: 50%; + animation: blinking-dots 1s linear infinite; + } } .status-message { @@ -223,32 +234,6 @@ } } -.build-trace { - background: $black; - color: $gray-darkest; - white-space: pre; - overflow-x: auto; - font-size: 12px; - position: relative; - - .fa-spinner { - font-size: 24px; - } - - .bash { - display: block; - } - - .build-loader-animation { - position: relative; - width: 6px; - height: 6px; - margin: auto auto 12px 2px; - border-radius: 50%; - animation: blinking-dots 1s linear infinite; - } -} - .right-sidebar.build-sidebar { padding: $gl-padding 0; @@ -390,6 +375,10 @@ .container-fluid.container-limited { max-width: 100%; } + + .content-wrapper { + padding-bottom: 6px; + } } .build-detail-row { diff --git a/app/assets/stylesheets/pages/ci_projects.scss b/app/assets/stylesheets/pages/ci_projects.scss index 90643832390..7b4eb689f1b 100644 --- a/app/assets/stylesheets/pages/ci_projects.scss +++ b/app/assets/stylesheets/pages/ci_projects.scss @@ -36,7 +36,6 @@ pre.commit-message { background: none; padding: 0; - margin: 0; border: none; margin: 20px 0; border-radius: 0; diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 9e3142c8aa3..9db0f2075cb 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -163,7 +163,6 @@ .avatar-cell { width: 46px; - padding-left: 10px; img { margin-right: 0; @@ -175,7 +174,6 @@ justify-content: space-between; align-items: flex-start; flex-grow: 1; - padding-left: 10px; .merge-request-branches & { flex-direction: column; @@ -208,11 +206,11 @@ margin-left: $gl-padding; } } -} -.commit-short-id { - font-family: $monospace_font; - font-weight: 600; + .commit-sha { + font-size: 14px; + font-weight: 600; + } } .commit, @@ -230,7 +228,7 @@ margin: 10px 0; background: $gray-light; display: none; - white-space: pre-line; + white-space: pre-wrap; word-break: normal; pre { @@ -273,7 +271,7 @@ } } - .commit-id { + .commit-sha { color: $gl-link-color; } diff --git a/app/assets/stylesheets/pages/convdev_index.scss b/app/assets/stylesheets/pages/convdev_index.scss new file mode 100644 index 00000000000..0413114c279 --- /dev/null +++ b/app/assets/stylesheets/pages/convdev_index.scss @@ -0,0 +1,255 @@ +$space-between-cards: 8px; + +.convdev-empty svg { + margin: 64px auto 32px; + max-width: 420px; +} + +.convdev-header { + margin-top: $gl-padding; + margin-bottom: $gl-padding; + padding: 0 4px; + display: flex; + align-items: center; + + .convdev-header-title { + font-size: 48px; + line-height: 1; + margin: 0; + } + + .convdev-header-subtitle { + font-size: 22px; + line-height: 1; + color: $gl-text-color-secondary; + margin-left: 8px; + font-weight: 500; + + a { + font-size: 18px; + color: $gl-text-color-secondary; + + &:hover { + color: $blue-500; + } + } + } +} + +.convdev-cards { + display: flex; + justify-content: center; + flex-wrap: wrap; +} + +.convdev-card-wrapper { + display: flex; + flex-direction: column; + align-items: stretch; + text-align: center; + width: 50%; + border-color: $border-color; + margin: 0 0 32px; + padding: $space-between-cards / 2; + position: relative; + + @media (min-width: $screen-xs-min) { + width: percentage(1 / 4); + } + + @media (min-width: $screen-sm-min) { + width: percentage(1 / 5); + } + + @media (min-width: $screen-md-min) { + width: percentage(1 / 6); + } + + @media (min-width: $screen-lg-min) { + width: percentage(1 / 10); + } +} + +.convdev-card { + border: solid 1px $border-color; + border-radius: 3px; + border-top-width: 3px; + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.convdev-card-low { + border-top-color: $color-low-score; + + .card-score-big { + background-color: $red-25; + } +} + +.convdev-card-average { + border-top-color: $color-average-score; + + .card-score-big { + background-color: $orange-25; + } +} + +.convdev-card-high { + border-top-color: $color-high-score; + + .card-score-big { + background-color: $green-25; + } +} + +.convdev-card-title { + margin: $gl-padding auto auto; + max-width: 100px; + + h3 { + font-size: 14px; + margin: 0 0 2px; + } + + .text-light { + font-size: 13px; + line-height: 1.25; + color: $gl-text-color-secondary; + } +} + +.card-scores { + display: flex; + justify-content: space-around; + align-items: center; + margin: $gl-padding $gl-btn-padding; + line-height: 1; +} + +.card-score { + color: $gl-text-color-secondary; + + .card-score-name { + font-size: 13px; + margin-top: 4px; + } +} + +.card-score-value { + font-size: 16px; + color: $gl-text-color; + font-weight: 500; +} + +.card-score-big { + border-top: 2px solid $border-color; + border-bottom: 1px solid $border-color; + font-size: 22px; + padding: 10px 0; + font-weight: 500; +} + +.card-buttons { + display: flex; + + > * { + font-size: 16px; + color: $gl-text-color-secondary; + padding: 10px; + flex-grow: 1; + + &:hover { + background-color: $border-color; + color: $gl-text-color; + } + + + * { + border-left: solid 1px $border-color; + } + } +} + +.convdev-steps { + margin-top: $gl-padding; + height: 1px; + min-width: 100%; + justify-content: space-around; + position: relative; + background: $border-color; +} + +.convdev-step { + $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%; + @each $pos in $step-positions { + $i: index($step-positions, $pos); + + &:nth-child(#{$i}) { + left: $pos; + } + } + + position: absolute; + transform-origin: 75% 50%; + padding: 8px; + height: 50px; + width: 50px; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + border: solid 1px $border-color; + background: $white-light; + transform: translate(-50%, -50%); + color: $gl-text-color-secondary; + fill: $gl-text-color-secondary; + box-shadow: 0 2px 4px $dropdown-shadow-color; + + &:hover { + padding: 8px 10px; + fill: currentColor; + z-index: 100; + height: auto; + width: auto; + + .convdev-step-title { + max-height: 2em; + opacity: 1; + transition: opacity 0.2s; + } + + svg { + transform: scale(1.5); + margin: $gl-btn-padding; + } + } + + svg { + transition: transform 0.1s; + width: 30px; + height: 30px; + min-height: 30px; + min-width: 30px; + } +} + +.convdev-step-title { + max-height: 0; + opacity: 0; + text-transform: uppercase; + margin: $gl-vert-padding 0 0; + text-align: center; + font-size: 12px; +} + +.convdev-high-score { + color: $color-high-score; +} + +.convdev-average-score { + color: $color-average-score; +} + +.convdev-low-score { + color: $color-low-score; +} diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index d29944207c5..7bec4bd5f56 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -387,7 +387,7 @@ padding: 0 3px 0 0; } - .branch-name { + .ref-name { color: $black; display: inline-block; max-width: 180px; @@ -398,7 +398,7 @@ vertical-align: top; } - .short-sha { + .commit-sha { color: $gl-link-color; line-height: 1.3; vertical-align: top; diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index f3de05aa5f6..3d9eff35583 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -4,11 +4,7 @@ color: $gl-text-color; line-height: 34px; - .author { - color: $gl-text-color; - } - - .identifier { + a { color: $gl-text-color; } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 77f2638683a..b58922626fa 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -1,38 +1,6 @@ // Common .diff-file { - border: 1px solid $border-color; margin-bottom: $gl-padding; - border-radius: 3px; - - .commit-short-id { - font-family: $regular_font; - font-weight: 400; - } - - .diff-header { - position: relative; - background: $gray-light; - border-bottom: 1px solid $border-color; - padding: 10px 16px; - color: $gl-text-color; - z-index: 10; - border-radius: 3px 3px 0 0; - - .diff-title { - font-family: $monospace_font; - word-break: break-all; - display: block; - - .file-mode { - color: $file-mode-changed; - } - } - - .commit-short-id { - font-family: $monospace_font; - font-size: smaller; - } - } .file-title, .file-title-flex-parent { @@ -126,7 +94,6 @@ .old_line, .new_line { margin: 0; - padding: 0; border: none; padding: 0 5px; border-right: 1px solid; @@ -183,14 +150,10 @@ } } } - - .text-file.diff-wrap-lines table .line_holder td span { - white-space: pre-wrap; - } } .image { - background: $diff-image-bg; + background: $file-image-bg; text-align: center; padding: 30px; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 026d35295d7..b24803678ea 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -5,45 +5,13 @@ } } -.environments-list-loading { - width: 100%; - font-size: 34px; -} - .environments-folder-name { font-weight: normal; padding-top: 20px; } .environments-container { - .table-holder { - width: 100%; - - @media (max-width: $screen-sm-max) { - overflow: auto; - } - } - - .table.ci-table { - .environments-actions { - min-width: 300px; - } - - .environments-commit, - .environments-actions { - width: 20%; - } - - .environments-date { - width: 10%; - } - - .environments-name, - .environments-deploy, - .environments-build { - width: 15%; - } - + .ci-table { .deployment-column { > span { word-break: break-all; @@ -69,12 +37,12 @@ } } - .commit-title { - margin: 0; + .btn .text-center { + display: inline; } - .avatar-image-container { - text-decoration: none; + .commit-title { + margin: 0; } .icon-play { @@ -95,7 +63,7 @@ } .build-link, - .branch-name { + .ref-name { color: $gl-text-color; } @@ -140,7 +108,7 @@ } .branch-commit { - .commit-id { + .commit-sha { margin-right: 0; } } @@ -155,6 +123,59 @@ } } +.gl-responsive-table-row { + .environments-actions { + @media (min-width: $screen-md-min) { + text-align: right; + } + + @media (max-width: $screen-sm-max) { + background-color: $gray-normal; + align-self: stretch; + border-top: 1px solid $border-color; + + .environment-action-buttons { + padding: 10px 5px; + display: flex; + + .btn { + border-radius: 3px; + } + + > .btn-group, + > .external-url, + > .btn { + flex: 1; + flex-basis: 28px; + margin: 0 5px; + } + + .dropdown-new { + width: 100%; + } + + .dropdown-menu { + min-width: initial; + } + } + } + } + + .branch-commit { + max-width: 100%; + } +} + +.folder-row { + padding: 15px 0; + border-bottom: 1px solid $white-normal; + + @media (max-width: $screen-sm-max) { + border-top: 1px solid $white-normal; + margin-top: 10px; + } +} + .prometheus-graph { text { fill: $gl-text-color; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index f8944e5ce03..e8334b9b779 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -198,8 +198,22 @@ right: 0; transition: width .3s; background: $gray-light; +<<<<<<< HEAD padding: 10px 20px; z-index: 200; +======= + padding: 0 20px; + z-index: 200; + overflow: hidden; + + .issuable-sidebar { + width: calc(100% + 100px); + height: 100%; + overflow-y: scroll; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 &.right-sidebar-expanded { width: $gutter_width; @@ -213,6 +227,10 @@ } } + .issuable-sidebar-header { + padding-top: 10px; + } + .assign-yourself .btn-link { padding-left: 0; } @@ -266,11 +284,10 @@ } width: $gutter_collapsed_width; - padding-top: 0; + padding: 0; .block { width: $gutter_collapsed_width - 2px; - margin-left: -19px; padding: 15px 0 0; border-bottom: none; overflow: hidden; @@ -419,7 +436,7 @@ } .detail-page-description { - padding: 16px 0 0; + padding: 16px 0; small { color: $gray-darkest; @@ -429,7 +446,7 @@ .edited-text { color: $gray-darkest; display: block; - margin: 0 0 16px; + margin: 16px 0 0; .author_link { color: $gray-darkest; @@ -717,3 +734,34 @@ } } } + +.confidential-issue-warning { + background-color: $gl-gray; + border-radius: 3px; + padding: $gl-btn-padding $gl-padding; + margin-top: $gl-padding-top; + font-size: 14px; + color: $white-light; + + .fa { + margin-right: 8px; + } + + a { + color: $white-light; + text-decoration: underline; + } + + &.affix { + position: static; + width: initial; + + @media (min-width: $screen-sm-min) { + position: sticky; + position: -webkit-sticky; + top: 60px; + z-index: 200; + @include transition(all); + } + } +} diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index ad3b6e0344b..702e7662527 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -51,6 +51,7 @@ ul.related-merge-requests > li { display: -ms-flexbox; display: -webkit-flex; display: flex; + align-items: center; .merge-request-id { flex-shrink: 0; @@ -59,6 +60,14 @@ ul.related-merge-requests > li { .merge-request-info { margin-left: 5px; } + + .row_title { + vertical-align: bottom; + } + + gl-emoji { + font-size: 1em; + } } .merge-requests-title, @@ -114,7 +123,6 @@ ul.related-merge-requests > li { .related-merge-requests { .ci-status-link { display: block; - margin-top: 3px; margin-right: 5px; } @@ -196,7 +204,6 @@ ul.related-merge-requests > li { .dropdown-toggle { .fa-caret-down { pointer-events: none; - margin-left: 0; color: inherit; margin-left: 0; } diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 8dbac76e30a..971d54e7472 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -184,4 +184,4 @@ } } } -}
\ No newline at end of file +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index f747e7b1660..ce42a146d63 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -132,12 +132,6 @@ line-height: 16px; } - @media (min-width: $screen-sm-min) { - .stage-cell { - padding: 0 4px; - } - } - @media (max-width: $screen-xs-max) { order: 1; margin-top: $gl-padding-top; @@ -165,6 +159,37 @@ .capitalize { text-transform: capitalize; +<<<<<<< HEAD +======= + } + + .label-branch { + @extend .ref-name; + + color: $gl-text-color; + font-weight: bold; + overflow: hidden; + margin: 0 3px; + word-break: break-all; + + &.label-truncated { + position: relative; + display: inline-block; + width: 250px; + margin-bottom: -3px; + white-space: nowrap; + text-overflow: clip; + line-height: 14px; + + &::after { + position: absolute; + content: '...'; + right: 0; + font-family: $regular_font; + background-color: $gray-light; + } + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } .js-deployment-link { @@ -349,6 +374,25 @@ margin-top: 10px; margin-left: 12px; } +<<<<<<< HEAD +======= + + &.empty-state { + .artwork { + margin-bottom: $gl-padding; + } + + .text { + span { + font-weight: bold; + } + + p { + margin-top: $gl-padding; + } + } + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } .mr-widget-footer { @@ -390,6 +434,7 @@ } } +<<<<<<< HEAD .label-branch { color: $gl-text-color; font-family: $monospace_font; @@ -418,6 +463,8 @@ } } +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 .commits-empty { text-align: center; @@ -504,6 +551,7 @@ margin: 6px 0; position: relative; padding-left: 15px; +<<<<<<< HEAD &::before { content: ''; @@ -521,6 +569,21 @@ &::before { top: 14px; } +======= + + &::before { + content: ''; + position: absolute; + border-top: 2px solid $border-color; + height: 1px; + top: 9px; + width: 8px; + left: 0; + } + + &:last-child { + margin-bottom: 0; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } } @@ -529,7 +592,11 @@ width: 2px; background: $border-color; position: absolute; +<<<<<<< HEAD top: -5px; +======= + top: -9px; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } } @@ -724,14 +791,24 @@ } .merge-request-tabs-holder { +<<<<<<< HEAD z-index: 100; background-color: $white-light; border-bottom: 1px solid $border-color; +======= + top: $header-height; + z-index: 100; + background-color: $white-light; + border-bottom: 1px solid $border-color; + + @media(min-width: $screen-sm-min) { + position: sticky; + position: -webkit-sticky; + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 &.affix { - top: 0; left: 0; - z-index: 10; transition: right .15s; @media (max-width: $screen-xs-max) { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 62f654ed343..aa307414737 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -28,7 +28,7 @@ .note-edit-form { .note-form-actions { position: relative; - margin: $gl-padding 0; + margin: $gl-padding 0 0; } .note-preview-holder { @@ -103,31 +103,19 @@ } } -.confidential-issue-warning { - background-color: $gray-normal; - border-radius: 3px; - padding: 3px 12px; - margin: auto; - margin-top: 0; - text-align: center; - font-size: 12px; - - @media (max-width: $screen-sm-max) { - // On smaller devices the warning becomes the fourth item in the list, - // rather than centering, and grows to span the full width of the - // comment area. - order: 4; - -webkit-order: 4; - margin: 6px auto; - width: 100%; - } -} - .discussion-form { - padding: $gl-padding-top $gl-padding; + padding: $gl-padding-top $gl-padding $gl-padding; background-color: $white-light; } +.discussion-notes .disabled-comment { + padding: 6px 0; +} + +.notes-form > li { + border: 0; +} + .note-edit-form { display: none; font-size: 14px; @@ -164,10 +152,6 @@ .discussion-body, .diff-file { - .notes .note { - padding: 10px 15px; - } - .discussion-reply-holder { background-color: $white-light; padding: 10px 16px; @@ -277,6 +261,7 @@ .toolbar-text { font-size: 14px; line-height: 16px; + margin-top: 2px; @media (min-width: $screen-md-min) { float: left; @@ -402,3 +387,45 @@ } } } + +.uploading-container { + float: right; + + @media (max-width: $screen-xs-max) { + float: left; + margin-top: 5px; + } +} + +.uploading-error-icon, +.uploading-error-message { + color: $gl-text-red; +} + +.uploading-error-message { + @media (max-width: $screen-xs-max) { + &::after { + content: "\a"; + white-space: pre; + } + } +} + +.uploading-progress { + margin-right: 5px; +} + +.attach-new-file, +.button-attach-file, +.retry-uploading-link { + color: $gl-link-color; + padding: 0; + background: none; + border: 0; + font-size: 14px; + line-height: 16px; +} + +.markdown-selector { + color: $gl-link-color; +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 99bcf612e8f..6831eccc276 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -14,24 +14,11 @@ ul.notes { margin: 0; padding: 0; - .timeline-icon { - float: left; - - svg { - width: 16px; - height: 16px; - fill: $gray-darkest; - position: absolute; - left: 0; - top: 16px; - } - } - .timeline-content { margin-left: 55px; &.timeline-content-form { - @media (max-width: $screen-sm-max) { + @include notes-media('max', $screen-sm-max) { margin-left: 0; } } @@ -51,26 +38,35 @@ ul.notes { } .discussion { - overflow: hidden; display: block; position: relative; + + .diff-content { + overflow: visible; + } } +<<<<<<< HEAD .note { padding: $gl-padding $gl-btn-padding 0; +======= + > li { + padding: $gl-padding $gl-btn-padding; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 display: block; position: relative; border-bottom: 1px solid $white-normal; + &:last-child { + // Override `.timeline > li:last-child { border-bottom: none; }` + border-bottom: 1px solid $white-normal; + } + &.being-posted { pointer-events: none; opacity: 0.5; .dummy-avatar { - display: inline-block; - height: 40px; - width: 40px; - border-radius: 50%; background-color: $kdb-border; border: 1px solid darken($kdb-border, 25%); } @@ -84,10 +80,13 @@ ul.notes { &.note-discussion { &.timeline-entry { padding: $gl-padding 10px; +<<<<<<< HEAD } .system-note { padding: 0; +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } } @@ -130,13 +129,13 @@ ul.notes { .note-awards { .js-awards-block { - margin-bottom: 16px; + margin-top: 16px; } } .note-header { - @media (max-width: $screen-xs-min) { + @include notes-media('max', $screen-xs-min) { .inline { display: block; } @@ -165,10 +164,10 @@ ul.notes { .system-note { font-size: 14px; - padding: 0; + padding-left: 0; clear: both; - @media (min-width: $screen-sm-min) { + @include notes-media('min', $screen-sm-min) { margin-left: 65px; } @@ -202,11 +201,22 @@ ul.notes { } } - .timeline-content { - padding: 14px 10px; + .timeline-icon { + float: left; - @media (min-width: $screen-sm-min) { - margin-left: 20px; + svg { + width: 16px; + height: 16px; + fill: $gray-darkest; + position: absolute; + left: 0; + top: 2px; + } + } + + .timeline-content { + @include notes-media('min', $screen-sm-min) { + margin-left: 30px; } } @@ -243,11 +253,6 @@ ul.notes { ul { margin: 3px 0 3px 16px !important; - - .gfm-commit { - font-family: $monospace_font; - font-size: 12px; - } } p:first-child { @@ -289,10 +294,6 @@ ul.notes { } } - .diff-header > span { - margin-right: 10px; - } - .line_content { white-space: pre-wrap; } @@ -384,7 +385,7 @@ ul.notes { display: flex; justify-content: space-between; - @media (max-width: $screen-xs-max) { + @include notes-media('max', $screen-xs-max) { flex-flow: row wrap; } } @@ -392,12 +393,25 @@ ul.notes { .note-header-info { min-width: 0; padding-bottom: 8px; +<<<<<<< HEAD +======= +} + +.system-note .note-header-info { + padding-bottom: 0; +} + +.note-header-author-name { + @include notes-media('max', $screen-xs-max) { + display: none; + } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } .note-headline-light { display: inline; - @media (max-width: $screen-xs-min) { + @include notes-media('max', $screen-xs-min) { display: block; } } @@ -439,7 +453,7 @@ ul.notes { margin-left: 10px; color: $gray-darkest; - @media (max-width: $screen-xs-max) { + @include notes-media('max', $screen-xs-max) { float: none; margin-left: 0; } @@ -447,10 +461,57 @@ ul.notes { .note-action-button { margin-left: 8px; } + + .more-actions-toggle { + margin-left: 2px; + } +} + +.more-actions { + display: inline; + + .tooltip { + white-space: nowrap; + } +} + +.more-actions-toggle { + padding: 0; + outline: none; + + &:hover .icon, + &:focus .icon { + color: $blue-600; + } + + .icon { + padding: 0 6px; + } +} + +.more-actions-dropdown { + width: 180px; + min-width: 180px; + margin-top: $gl-btn-padding; + + li > a, + li > .btn { + color: $gl-text-color; + padding: $gl-btn-padding; + width: 100%; + text-align: left; + + &:hover, + &:focus { + color: $gl-text-color; + background-color: $blue-25; + border-radius: $border-radius-default; + } + } } .discussion-actions { - @media (max-width: $screen-md-max) { + @include notes-media('max', $screen-md-max) { float: none; margin-left: 0; @@ -464,7 +525,7 @@ ul.notes { display: inline; line-height: 20px; - @media (min-width: $screen-sm-min) { + @include notes-media('min', $screen-sm-min) { margin-left: 10px; line-height: 24px; } @@ -554,13 +615,13 @@ ul.notes { position: relative; top: -2px; display: inline-block; - padding-left: 4px; - padding-right: 4px; + padding-left: 7px; + padding-right: 7px; color: $notes-role-color; font-size: 12px; line-height: 20px; border: 1px solid $border-color; - border-radius: $border-radius-base; + border-radius: $label-border-radius; } @@ -596,6 +657,22 @@ ul.notes { } } +.discussion-body, +.diff-file { + .notes .note { + padding-left: $gl-padding; + padding-right: $gl-padding; + + &.system-note { + padding-left: 0; + + @media (min-width: $screen-sm-min) { + margin-left: 70px; + } + } + } +} + .diff-file { .is-over { .add-diff-note { @@ -605,17 +682,11 @@ ul.notes { } .disabled-comment { - margin-left: -$gl-padding-top; - margin-right: -$gl-padding-top; background-color: $gray-light; border-radius: $border-radius-base; border: 1px solid $border-gray-normal; color: $note-disabled-comment-color; - line-height: 200px; - - .disabled-comment-text { - line-height: normal; - } + padding: 90px 0; a { color: $gl-link-color; @@ -623,7 +694,11 @@ ul.notes { } .line-resolve-all-container { +<<<<<<< HEAD @media (min-width: $screen-sm-min) { +======= + @include notes-media('min', $screen-sm-min) { +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 margin-right: 0; padding-left: $gl-padding; } @@ -665,7 +740,7 @@ ul.notes { .line-resolve-all { vertical-align: middle; display: inline-block; - padding: 6px 10px; + padding: 5px 10px 6px; background-color: $gray-light; border: 1px solid $border-color; border-radius: $border-radius-default; @@ -678,6 +753,10 @@ ul.notes { .line-resolve-btn { margin-right: 5px; + + svg { + vertical-align: middle; + } } } @@ -714,6 +793,10 @@ ul.notes { } } +.line-resolve-text { + vertical-align: middle; +} + .discussion-next-btn { svg { margin: 0; @@ -730,11 +813,6 @@ ul.notes { // Merge request notes in diffs .diff-file { - // Diff is side by side - .notes_content.parallel .note-header .note-headline-light { - display: block; - position: relative; - } // Diff is inline .notes_content .note-header .note-headline-light { display: inline-block; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 4304e736b58..58b458cd837 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -1,10 +1,4 @@ .pipelines { - .realtime-loading { - font-size: 40px; - text-align: center; - margin: 0 auto; - } - .stage { max-width: 90px; width: 90px; @@ -14,10 +8,6 @@ white-space: nowrap; } - .empty-state { - margin: 5% auto 0; - } - .table-holder { width: 100%; @@ -98,6 +88,10 @@ } } + .btn .text-center { + display: inline; + } + .tooltip { white-space: nowrap; } @@ -168,9 +162,13 @@ float: none; } + .api { + @extend .monospace; + } + .branch-commit { - .branch-name { + .ref-name { font-weight: bold; max-width: 120px; overflow: hidden; @@ -192,12 +190,11 @@ color: $gl-text-color; } - .commit-id { + .commit-sha { color: $gl-link-color; } .commit-title { - margin-top: 4px; max-width: 225px; overflow: hidden; white-space: nowrap; @@ -230,7 +227,7 @@ .duration, .finished-at { color: $gl-text-color-secondary; - margin: 4px 0; + margin: 0; white-space: nowrap; .fa { @@ -257,7 +254,7 @@ .stage-cell { font-size: 0; - padding: 10px 4px; + padding: 0 4px; > .stage-container > div > button > span > svg, > .stage-container > button > svg { @@ -987,3 +984,11 @@ width: 12px; } } + +.pipeline-header-container { + min-height: 55px; + + .text-center { + padding-top: 12px; + } +} diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index fe084eb9397..c207159f606 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -287,6 +287,7 @@ table.u2f-registrations { .user-callout { margin: 0 auto; + max-width: $screen-lg-min; .bordered-box { border: 1px solid $blue-300; @@ -295,14 +296,15 @@ table.u2f-registrations { position: relative; display: flex; justify-content: center; + align-items: center; } .landing { - margin-top: $gl-padding; - margin-bottom: $gl-padding; + padding: 32px; .close { position: absolute; + top: 20px; right: 20px; opacity: 1; @@ -330,11 +332,20 @@ table.u2f-registrations { height: 110px; vertical-align: top; } + + &.convdev { + margin: 0 0 0 30px; + + svg { + height: 127px; + } + } } .user-callout-copy { display: inline-block; vertical-align: top; + max-width: 570px; } } @@ -348,12 +359,20 @@ table.u2f-registrations { .landing { .svg-container, .user-callout-copy { - margin: 0; + margin: 0 auto; display: block; svg { height: 75px; } + + &.convdev { + margin: $gl-padding auto 0; + + svg { + height: 120px; + } + } } } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index c119f0c9b22..a2f781a6a6e 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -29,6 +29,20 @@ & > .form-group { padding-left: 0; } + + select option[disabled] { + display: none; + } + } + + select { + background: transparent; + transition: background 2s ease-out; + + &.highlight-changes { + background: $highlight-changes-color; + transition: none; + } } .help-block { @@ -247,7 +261,6 @@ font-size: 13px; font-weight: 600; line-height: 13px; - padding: $gl-vert-padding $gl-padding; letter-spacing: .4px; padding: 6px 14px; text-align: center; @@ -384,10 +397,6 @@ a.deploy-project-label { } } -.last-push-widget { - margin-top: -1px; -} - .fork-namespaces { .row { -webkit-flex-wrap: wrap; @@ -639,59 +648,6 @@ pre.light-well { } } -.project-last-commit { - background-color: $gray-light; - border: 1px solid $border-color; - border-radius: $border-radius-base; - padding: 12px; - - @media (min-width: $screen-sm-min) { - margin-top: $gl-padding; - } - - .ci-status { - margin-right: $gl-padding; - } - - .commit-row-message { - color: $gl-text-color; - } - - .commit_short_id { - margin-right: 5px; - color: $gl-link-color; - font-weight: 600; - } - - .commit-author-link { - .commit-author-name { - font-weight: 600; - } - } -} - -.project-show-readme { - .row-content-block { - background-color: inherit; - border: none; - } - - .readme-holder { - padding: $gl-padding 0; - border-top: 0; - - .edit-project-readme { - z-index: 2; - position: relative; - } - - .wiki h1 { - border-bottom: none; - padding: 0; - } - } -} - .git-clone-holder { width: 380px; @@ -733,14 +689,16 @@ pre.light-well { } } -.new_protected_branch { +.new_protected_branch, +.new-protected-tag { label { margin-top: 6px; font-weight: normal; } } -.create-new-protected-branch-button { +.create-new-protected-branch-button, +.create-new-protected-tag-button { @include dropdown-link; width: 100%; @@ -825,7 +783,8 @@ pre.light-well { } .compare-form-group { - .dropdown-menu { + .dropdown-menu, + .inline-input-group { width: 100%; @media (min-width: $screen-sm-min) { @@ -844,14 +803,6 @@ pre.light-well { width: auto; } } - - .inline-input-group { - width: 100%; - - @media (min-width: $screen-sm-min) { - width: 250px; - } - } } .clearable-input { diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 3889deee21a..33b3c083fd2 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -1,3 +1,90 @@ +@keyframes expandMaxHeight { + 0% { + max-height: 0; + } + + 99% { + max-height: 100vh; + } + + 100% { + max-height: none; + } +} + +@keyframes collapseMaxHeight { + 0% { + max-height: 100vh; + } + + 100% { + max-height: 0; + } +} + +.settings { + overflow: hidden; + border-bottom: 1px solid $gray-darker; + + &:first-of-type { + margin-top: 10px; + } +} + +.settings-header { + position: relative; + padding: 20px 110px 10px 0; + + h4 { + margin-top: 0; + } + + button { + position: absolute; + top: 20px; + right: 6px; + min-width: 80px; + } +} + +.settings-content { + max-height: 1px; + overflow-y: scroll; + margin-right: -20px; + padding-right: 130px; + animation: collapseMaxHeight 300ms ease-out; + + &.expanded { + max-height: none; + overflow-y: visible; + animation: expandMaxHeight 300ms ease-in; + } + + &.no-animate { + animation: none; + } + + @media(max-width: $screen-sm-max) { + padding-right: 20px; + } + + &::before { + content: ' '; + display: block; + height: 1px; + overflow: hidden; + margin-bottom: 4px; + } + + &::after { + content: ' '; + display: block; + height: 1px; + overflow: hidden; + margin-top: 20px; + } +} + .settings-list-icon { color: $gl-text-color-secondary; font-size: $settings-icon-size; diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 03c75ce61f5..ab63225147f 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -138,11 +138,12 @@ .blob-commit-info { list-style: none; - background: $gray-light; - padding: 16px 16px 16px 6px; - border: 1px solid $border-color; - border-bottom: none; margin: 0; + padding: 0; +} + +.blob-content-holder { + margin-top: $gl-padding; } .blob-upload-dropzone-previews { diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 6cc1cc8e263..136d0c79467 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -28,9 +28,6 @@ nav.navbar-collapse.collapse, .profiler-results, .tree-ref-holder, .tree-holder .breadcrumb, -.blob-commit-info, -.file-title, -.file-holder, .nav, .btn, ul.notes-form, @@ -43,6 +40,11 @@ ul.notes-form, display: none!important; } +pre { + page-break-before: avoid; + page-break-inside: auto; +} + .page-gutter { padding-top: 0; padding-left: 0; diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 152d7baad49..75fb19e815f 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -149,6 +149,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :version_check_enabled, :terminal_max_session_time, :polling_interval_multiplier, + :prometheus_metrics_enabled, :usage_ping_enabled, disabled_oauth_sign_in_sources: [], diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 9c9f420c1e0..434ff6b2a62 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -39,7 +39,7 @@ class Admin::ApplicationsController < Admin::ApplicationController def destroy @application.destroy - redirect_to admin_applications_url, notice: 'Application was successfully destroyed.' + redirect_to admin_applications_url, status: 302, notice: 'Application was successfully destroyed.' end private diff --git a/app/controllers/admin/conversational_development_index_controller.rb b/app/controllers/admin/conversational_development_index_controller.rb new file mode 100644 index 00000000000..921169d3e2b --- /dev/null +++ b/app/controllers/admin/conversational_development_index_controller.rb @@ -0,0 +1,5 @@ +class Admin::ConversationalDevelopmentIndexController < Admin::ApplicationController + def show + @metric = ConversationalDevelopmentIndex::Metric.order(:created_at).last&.present + end +end diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb index 4f6a7e9e2cb..e5cba774dcb 100644 --- a/app/controllers/admin/deploy_keys_controller.rb +++ b/app/controllers/admin/deploy_keys_controller.rb @@ -1,6 +1,6 @@ class Admin::DeployKeysController < Admin::ApplicationController before_action :deploy_keys, only: [:index] - before_action :deploy_key, only: [:destroy] + before_action :deploy_key, only: [:destroy, :edit, :update] def index end @@ -10,12 +10,24 @@ class Admin::DeployKeysController < Admin::ApplicationController end def create - @deploy_key = deploy_keys.new(deploy_key_params.merge(user: current_user)) + @deploy_key = deploy_keys.new(create_params.merge(user: current_user)) if @deploy_key.save redirect_to admin_deploy_keys_path else - render "new" + render 'new' + end + end + + def edit + end + + def update + if deploy_key.update_attributes(update_params) + flash[:notice] = 'Deploy key was successfully updated.' + redirect_to admin_deploy_keys_path + else + render 'edit' end end @@ -23,7 +35,7 @@ class Admin::DeployKeysController < Admin::ApplicationController deploy_key.destroy respond_to do |format| - format.html { redirect_to admin_deploy_keys_path } + format.html { redirect_to admin_deploy_keys_path, status: 302 } format.json { head :ok } end end @@ -38,7 +50,11 @@ class Admin::DeployKeysController < Admin::ApplicationController @deploy_keys ||= DeployKey.are_public end - def deploy_key_params + def create_params params.require(:deploy_key).permit(:key, :title, :can_push) end + + def update_params + params.require(:deploy_key).permit(:title, :can_push) + end end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 5885b3543bb..2ce26de1768 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -43,19 +43,22 @@ class Admin::GroupsController < Admin::ApplicationController end def members_update - status = Members::CreateService.new(@group, current_user, params).execute + member_params = params.permit(:user_ids, :access_level, :expires_at) + result = Members::CreateService.new(@group, current_user, member_params.merge(limit: -1)).execute - if status + if result[:status] == :success redirect_to [:admin, @group], notice: 'Users were successfully added.' else - redirect_to [:admin, @group], alert: 'No users specified.' + redirect_to [:admin, @group], alert: result[:message] end end def destroy Groups::DestroyService.new(@group, current_user).async_execute - redirect_to admin_groups_path, alert: "Group '#{@group.name}' was scheduled for deletion." + redirect_to admin_groups_path, + status: 302, + alert: "Group '#{@group.name}' was scheduled for deletion." end private diff --git a/app/controllers/admin/hook_logs_controller.rb b/app/controllers/admin/hook_logs_controller.rb new file mode 100644 index 00000000000..aa069b89563 --- /dev/null +++ b/app/controllers/admin/hook_logs_controller.rb @@ -0,0 +1,29 @@ +class Admin::HookLogsController < Admin::ApplicationController + include HooksExecution + + before_action :hook, only: [:show, :retry] + before_action :hook_log, only: [:show, :retry] + + respond_to :html + + def show + end + + def retry + status, message = hook.execute(hook_log.request_data, hook_log.trigger) + + set_hook_execution_notice(status, message) + + redirect_to edit_admin_hook_path(@hook) + end + + private + + def hook + @hook ||= SystemHook.find(params[:hook_id]) + end + + def hook_log + @hook_log ||= hook.web_hook_logs.find(params[:id]) + end +end diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index a119934febc..054c3500b35 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -1,5 +1,7 @@ class Admin::HooksController < Admin::ApplicationController - before_action :hook, only: :edit + include HooksExecution + + before_action :hook_logs, only: :edit def index @hooks = SystemHook.all @@ -32,19 +34,13 @@ class Admin::HooksController < Admin::ApplicationController def destroy hook.destroy - redirect_to admin_hooks_path + redirect_to admin_hooks_path, status: 302 end def test - data = { - event_name: "project_create", - name: "Ruby", - path: "ruby", - project_id: 1, - owner_name: "Someone", - owner_email: "example@gitlabhq.com" - } - hook.execute(data, 'system_hooks') + status, message = hook.execute(sample_hook_data, 'system_hooks') + + set_hook_execution_notice(status, message) redirect_back_or_default end @@ -55,13 +51,30 @@ class Admin::HooksController < Admin::ApplicationController @hook ||= SystemHook.find(params[:id]) end + def hook_logs + @hook_logs ||= + Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page]) + end + def hook_params params.require(:hook).permit( :enable_ssl_verification, :push_events, :tag_push_events, + :repository_update_events, :token, :url ) end + + def sample_hook_data + { + event_name: "project_create", + name: "Ruby", + path: "ruby", + project_id: 1, + owner_name: "Someone", + owner_email: "example@gitlabhq.com" + } + end end diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb index 79a53556f0a..43b4e3a2cc3 100644 --- a/app/controllers/admin/identities_controller.rb +++ b/app/controllers/admin/identities_controller.rb @@ -36,9 +36,9 @@ class Admin::IdentitiesController < Admin::ApplicationController def destroy if @identity.destroy RepairLdapBlockedUserService.new(@user).execute - redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully removed.' + redirect_to admin_user_identities_path(@user), status: 302, notice: 'User identity was successfully removed.' else - redirect_to admin_user_identities_path(@user), alert: 'Failed to remove user identity.' + redirect_to admin_user_identities_path(@user), status: 302, alert: 'Failed to remove user identity.' end end diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb index 8e7adc06584..39dbf85f6c0 100644 --- a/app/controllers/admin/impersonations_controller.rb +++ b/app/controllers/admin/impersonations_controller.rb @@ -11,7 +11,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController session[:impersonator_id] = nil - redirect_to admin_user_path(original_user) + redirect_to admin_user_path(original_user), status: 302 end private diff --git a/app/controllers/admin/builds_controller.rb b/app/controllers/admin/jobs_controller.rb index 88f3c0e2fd4..5162273ef8a 100644 --- a/app/controllers/admin/builds_controller.rb +++ b/app/controllers/admin/jobs_controller.rb @@ -1,4 +1,4 @@ -class Admin::BuildsController < Admin::ApplicationController +class Admin::JobsController < Admin::ApplicationController def index @scope = params[:scope] @all_builds = Ci::Build @@ -20,6 +20,6 @@ class Admin::BuildsController < Admin::ApplicationController def cancel_all Ci::Build.running_or_pending.each(&:cancel) - redirect_to admin_builds_path + redirect_to admin_jobs_path end end diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb index 054bb52b696..0b76193a90e 100644 --- a/app/controllers/admin/keys_controller.rb +++ b/app/controllers/admin/keys_controller.rb @@ -15,9 +15,9 @@ class Admin::KeysController < Admin::ApplicationController respond_to do |format| if key.destroy - format.html { redirect_to [:admin, user], notice: 'User key was successfully removed.' } + format.html { redirect_to keys_admin_user_path(user), status: 302, notice: 'User key was successfully removed.' } else - format.html { redirect_to [:admin, user], alert: 'Failed to remove user key.' } + format.html { redirect_to keys_admin_user_path(user), status: 302, alert: 'Failed to remove user key.' } end end end diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index 4531657268c..cbc7a14ae83 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -41,7 +41,7 @@ class Admin::LabelsController < Admin::ApplicationController respond_to do |format| format.html do - redirect_to(admin_labels_path, notice: 'Label was removed') + redirect_to admin_labels_path, status: 302, notice: 'Label was removed' end format.js end diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb index 70ac6a75434..7ed2de71028 100644 --- a/app/controllers/admin/runner_projects_controller.rb +++ b/app/controllers/admin/runner_projects_controller.rb @@ -18,7 +18,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController runner = rp.runner rp.destroy - redirect_to admin_runner_path(runner) + redirect_to admin_runner_path(runner), status: 302 end private diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 348641e5ecb..719893c0bc8 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -27,7 +27,7 @@ class Admin::RunnersController < Admin::ApplicationController def destroy @runner.destroy - redirect_to admin_runners_path + redirect_to admin_runners_path, status: 302 end def resume diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 1d66955bb71..d52d67a67a5 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -8,7 +8,9 @@ class Admin::SpamLogsController < Admin::ApplicationController if params[:remove_user] spam_log.remove_user(deleted_by: current_user) - redirect_to admin_spam_logs_path, notice: "User #{spam_log.user.username} was successfully removed." + redirect_to admin_spam_logs_path, + status: 302, + notice: "User #{spam_log.user.username} was successfully removed." else spam_log.destroy head :ok diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 563bcc65bd6..b09eef17c23 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -138,10 +138,10 @@ class Admin::UsersController < Admin::ApplicationController end def destroy - DeleteUserWorker.perform_async(current_user.id, user.id) + user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete)) respond_to do |format| - format.html { redirect_to admin_users_path, notice: "The user is being deleted." } + format.html { redirect_to admin_users_path, status: 302, notice: "The user is being deleted." } format.json { head :ok } end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8ce9150e4a9..47ce21d238b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base include EnforcesTwoFactorAuthentication before_action :authenticate_user_from_private_token! + before_action :authenticate_user_from_rss_token! before_action :authenticate_user! before_action :validate_user_service_ticket! before_action :check_password_expiration @@ -72,13 +73,20 @@ class ApplicationController < ActionController::Base user = User.find_by_authentication_token(token) || User.find_by_personal_access_token(token) - if user && can?(user, :log_in) - # Notice we are passing store false, so the user is not - # actually stored in the session and a token is needed - # for every request. If you want the token to work as a - # sign in token, you can simply remove store: false. - sign_in user, store: false - end + sessionless_sign_in(user) + end + + # This filter handles authentication for atom request with an rss_token + def authenticate_user_from_rss_token! + return unless request.format.atom? + + token = params[:rss_token].presence + + return unless token.present? + + user = User.find_by_rss_token(token) + + sessionless_sign_in(user) end def log_exception(exception) @@ -275,11 +283,17 @@ class ApplicationController < ActionController::Base request.base_url end - def set_locale - Gitlab::I18n.set_locale(current_user) + def set_locale(&block) + Gitlab::I18n.with_user_locale(current_user, &block) + end - yield - ensure - Gitlab::I18n.reset_locale + def sessionless_sign_in(user) + if user && can?(user, :log_in) + # Notice we are passing store false, so the user is not + # actually stored in the session and a token is needed + # for every request. If you want the token to work as a + # sign in token, you can simply remove store: false. + sign_in user, store: false + end end end diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index f94f88305a4..fe331a883c1 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -9,7 +9,7 @@ class AutocompleteController < ApplicationController @users = @users.where.not(id: params[:skip_users]) if params[:skip_users].present? @users = @users.active @users = @users.reorder(:name) - @users = @users.page(params[:page]) + @users = @users.page(params[:page]).per(params[:per_page]) if params[:todo_filter].present? && current_user @users = @users.todo_authors(current_user.id, params[:todo_state_filter]) @@ -41,7 +41,7 @@ class AutocompleteController < ApplicationController no_project = { id: 0, - name_with_namespace: 'No project', + name_with_namespace: 'No project' } projects.unshift(no_project) unless params[:offset_id].present? diff --git a/app/controllers/concerns/diff_for_path.rb b/app/controllers/concerns/diff_for_path.rb index 1efa9fe060f..d5388c4cd20 100644 --- a/app/controllers/concerns/diff_for_path.rb +++ b/app/controllers/concerns/diff_for_path.rb @@ -8,17 +8,6 @@ module DiffForPath return render_404 unless diff_file - diff_commit = commit_for_diff(diff_file) - blob = diff_file.blob(diff_commit) - - locals = { - diff_file: diff_file, - diff_commit: diff_commit, - diff_refs: diffs.diff_refs, - blob: blob, - project: project - } - - render json: { html: view_to_html_string('projects/diffs/_content', locals) } + render json: { html: view_to_html_string('projects/diffs/_content', diff_file: diff_file) } end end diff --git a/app/controllers/concerns/hooks_execution.rb b/app/controllers/concerns/hooks_execution.rb new file mode 100644 index 00000000000..846cd60518f --- /dev/null +++ b/app/controllers/concerns/hooks_execution.rb @@ -0,0 +1,15 @@ +module HooksExecution + extend ActiveSupport::Concern + + private + + def set_hook_execution_notice(status, message) + if status && status >= 200 && status < 400 + flash[:notice] = "Hook executed successfully: HTTP #{status}" + elsif status + flash[:alert] = "Hook executed successfully but returned HTTP #{status} #{message}" + else + flash[:alert] = "Hook execution failed: #{message}" + end + end +end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 4cf645d6341..0c3b68a7ac3 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -14,7 +14,16 @@ module IssuableActions name = issuable.human_class_name flash[:notice] = "The #{name} was successfully deleted." - redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) + index_path = polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) + + respond_to do |format| + format.html { redirect_to index_path } + format.json do + render json: { + web_url: index_path + } + end + end end def bulk_update diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 6df2c068745..650ec1e326a 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -47,7 +47,7 @@ module IssuableCollections end def merge_requests_collection - merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, target_project: :namespace) + merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, :head_pipeline, target_project: :namespace) end def issues_finder diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index ed22b1e5470..2b6afaa6233 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -23,7 +23,7 @@ module LfsRequest render( json: { message: 'Git LFS is not enabled on this GitLab server, contact your admin.', - documentation_url: help_url, + documentation_url: help_url }, status: 501 ) @@ -48,7 +48,7 @@ module LfsRequest render( json: { message: 'Access forbidden. Check your access level.', - documentation_url: help_url, + documentation_url: help_url }, content_type: "application/vnd.git-lfs+json", status: 403 @@ -59,7 +59,7 @@ module LfsRequest render( json: { message: 'Not found.', - documentation_url: help_url, + documentation_url: help_url }, content_type: "application/vnd.git-lfs+json", status: 404 @@ -106,4 +106,8 @@ module LfsRequest def objects @objects ||= (params[:objects] || []).to_a end + + def has_authentication_ability?(capability) + (authentication_abilities || []).include?(capability) + end end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index b1bacc8ffe5..cefb9b4e766 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -2,14 +2,15 @@ module MembershipActions extend ActiveSupport::Concern def create - status = Members::CreateService.new(membershipable, current_user, params).execute + create_params = params.permit(:user_ids, :access_level, :expires_at) + result = Members::CreateService.new(membershipable, current_user, create_params).execute redirect_url = members_page_url - if status + if result[:status] == :success redirect_to redirect_url, notice: 'Users were successfully added.' else - redirect_to redirect_url, alert: 'No users specified.' + redirect_to redirect_url, alert: result[:message] end end diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb index 9faf68e6d97..54dcd7c61ce 100644 --- a/app/controllers/concerns/renders_blob.rb +++ b/app/controllers/concerns/renders_blob.rb @@ -3,19 +3,22 @@ module RendersBlob def render_blob_json(blob) viewer = - if params[:viewer] == 'rich' + case params[:viewer] + when 'rich' blob.rich_viewer + when 'auxiliary' + blob.auxiliary_viewer else blob.simple_viewer end return render_404 unless viewer render json: { - html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_asynchronously: false) + html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_async: false) } end - def override_max_blob_size(blob) - blob.override_max_size! if params[:override_max_size] == 'true' + def conditionally_expand_blob(blob) + blob.expand! if params[:expanded] == 'true' end end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 5a1efcab1a3..3d49ea97591 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -8,7 +8,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController @projects = load_projects(params.merge(non_public: true)).page(params[:page]) respond_to do |format| - format.html { @last_push = current_user.recent_push } + format.html format.atom do load_events render layout: false @@ -25,7 +25,6 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController @projects = load_projects(params.merge(starred: true)). includes(:forked_from_project, :tags).page(params[:page]) - @last_push = current_user.recent_push @groups = [] respond_to do |format| diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 4d7d45787fc..623392c1240 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -15,7 +15,11 @@ class Dashboard::TodosController < Dashboard::ApplicationController 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.' } + format.html do + redirect_to dashboard_todos_path, + status: 302, + notice: 'Todo was successfully marked as done.' + end format.js { head :ok } format.json { render json: todos_counts } end @@ -25,7 +29,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user) respond_to do |format| - format.html { redirect_to dashboard_todos_path, notice: 'All todos were marked as done.' } + format.html { redirect_to dashboard_todos_path, status: 302, notice: 'All todos were marked as done.' } format.js { head :ok } format.json { render json: todos_counts.merge(updated_ids: updated_ids) } end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 79d420a32d3..f9c31920302 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -9,8 +9,6 @@ class DashboardController < Dashboard::ApplicationController respond_to :html def activity - @last_push = current_user.recent_push - respond_to do |format| format.html @@ -26,7 +24,7 @@ class DashboardController < Dashboard::ApplicationController def load_events projects = if params[:filter] == "starred" - current_user.viewable_starred_projects + ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute else current_user.authorized_projects end diff --git a/app/controllers/groups/avatars_controller.rb b/app/controllers/groups/avatars_controller.rb index ad2c20b42db..735915abdaa 100644 --- a/app/controllers/groups/avatars_controller.rb +++ b/app/controllers/groups/avatars_controller.rb @@ -5,6 +5,6 @@ class Groups::AvatarsController < Groups::ApplicationController @group.remove_avatar! @group.save - redirect_to edit_group_path(@group) + redirect_to edit_group_path(@group), status: 302 end end diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index 3fa0516fb0c..dda59262483 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -54,7 +54,7 @@ class Groups::LabelsController < Groups::ApplicationController respond_to do |format| format.html do - redirect_to group_labels_path(@group), notice: 'Label was removed' + redirect_to group_labels_path(@group), status: 302, notice: 'Label was removed' end format.js end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index ebbcc10dd35..f20fa262205 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -101,7 +101,7 @@ class GroupsController < Groups::ApplicationController def destroy Groups::DestroyService.new(@group, current_user).async_execute - redirect_to root_path, alert: "Group '#{@group.name}' was scheduled for deletion." + redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion." end protected @@ -167,14 +167,17 @@ class GroupsController < Groups::ApplicationController def user_actions if current_user - @last_push = current_user.recent_push @notification_setting = current_user.notification_settings_for(group) end end def build_canonical_path(group) return group_path(group) if action_name == 'show' # root group path +<<<<<<< HEAD +======= + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 params[:id] = group.to_param url_for(params) diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index df0fc3132ed..abc832e6ddc 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -5,7 +5,7 @@ class HealthController < ActionController::Base CHECKS = [ Gitlab::HealthChecks::DbCheck, Gitlab::HealthChecks::RedisCheck, - Gitlab::HealthChecks::FsShardsCheck, + Gitlab::HealthChecks::FsShardsCheck ].freeze def readiness @@ -20,25 +20,8 @@ class HealthController < ActionController::Base render_check_results(results) end - def metrics - results = CHECKS.flat_map(&:metrics) - - response = results.map(&method(:metric_to_prom_line)).join("\n") - - render text: response, content_type: 'text/plain; version=0.0.4' - end - private - def metric_to_prom_line(metric) - labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || '' - if labels.empty? - "#{metric.name} #{metric.value}" - else - "#{metric.name}{#{labels}} #{metric.value}" - end - end - def render_check_results(results) flattened = results.flat_map do |name, result| if result.is_a?(Gitlab::HealthChecks::Result) diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 3109439b2ff..c585d26df77 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -4,7 +4,7 @@ class JwtController < ApplicationController before_action :authenticate_project_or_user SERVICES = { - Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService, + Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService }.freeze def auth @@ -25,8 +25,10 @@ class JwtController < ApplicationController authenticate_with_http_basic do |login, password| @authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip) - render_unauthorized unless @authentication_result.success? && - (@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User)) + if @authentication_result.failed? || + (@authentication_result.actor.present? && !@authentication_result.actor.is_a?(User)) + render_unauthorized + end end rescue Gitlab::Auth::MissingPersonalTokenError render_missing_personal_token diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb new file mode 100644 index 00000000000..0e9a19c0b6f --- /dev/null +++ b/app/controllers/metrics_controller.rb @@ -0,0 +1,21 @@ +class MetricsController < ActionController::Base + include RequiresHealthToken + + protect_from_forgery with: :exception + + before_action :validate_prometheus_metrics + + def index + render text: metrics_service.metrics_text, content_type: 'text/plain; verssion=0.0.4' + end + + private + + def metrics_service + @metrics_service ||= MetricsService.new + end + + def validate_prometheus_metrics + render_404 unless Gitlab::Metrics.prometheus_metrics_enabled? + end +end diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 4193ac11399..656107d2b26 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -10,6 +10,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio Doorkeeper::AccessToken.revoke_all_for(params[:id], current_resource_owner) end - redirect_to applications_profile_url, notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy]) + redirect_to applications_profile_url, + status: 302, + notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy]) end end diff --git a/app/controllers/profiles/avatars_controller.rb b/app/controllers/profiles/avatars_controller.rb index daa51ae41df..933e0f3bceb 100644 --- a/app/controllers/profiles/avatars_controller.rb +++ b/app/controllers/profiles/avatars_controller.rb @@ -5,6 +5,6 @@ class Profiles::AvatarsController < Profiles::ApplicationController @user.save - redirect_to profile_path + redirect_to profile_path, status: 302 end end diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb index 6a1f468ba5a..2353f0840d6 100644 --- a/app/controllers/profiles/chat_names_controller.rb +++ b/app/controllers/profiles/chat_names_controller.rb @@ -39,7 +39,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController flash[:alert] = "Could not delete chat nickname #{@chat_name.chat_name}." end - redirect_to profile_chat_names_path + redirect_to profile_chat_names_path, status: 302 end private diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb index 1c24c4db993..5655fb2ba0e 100644 --- a/app/controllers/profiles/emails_controller.rb +++ b/app/controllers/profiles/emails_controller.rb @@ -23,7 +23,7 @@ class Profiles::EmailsController < Profiles::ApplicationController current_user.update_secondary_emails! respond_to do |format| - format.html { redirect_to profile_emails_url } + format.html { redirect_to profile_emails_url, status: 302 } format.js { head :ok } end end diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index e4452f46056..88f49da555a 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -26,7 +26,7 @@ class Profiles::KeysController < Profiles::ApplicationController @key.destroy respond_to do |format| - format.html { redirect_to profile_keys_url } + format.html { redirect_to profile_keys_url, status: 302 } format.js { head :ok } end end diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 0abe7ea3c9b..f748d191ef4 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -38,7 +38,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController end def set_index_vars - @scopes = Gitlab::Auth::API_SCOPES + @scopes = Gitlab::Auth::AVAILABLE_SCOPES @personal_access_token = finder.build @inactive_personal_access_tokens = finder(state: 'inactive').execute diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 0d891ef4004..5414142e2df 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -33,7 +33,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController :color_scheme_id, :layout, :dashboard, - :project_view, + :project_view ) end end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index d3fa81cd623..313cdcd1c15 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -77,7 +77,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def destroy current_user.disable_two_factor! - redirect_to profile_account_path + redirect_to profile_account_path, status: 302 end def skip diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb index c02fe85c3cc..e3d7737f44a 100644 --- a/app/controllers/profiles/u2f_registrations_controller.rb +++ b/app/controllers/profiles/u2f_registrations_controller.rb @@ -2,6 +2,6 @@ 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." + redirect_to profile_two_factor_auth_path, status: 302, notice: "Successfully deleted U2F device." end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 57e23cea00e..72f34930ca8 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -9,7 +9,7 @@ class ProfilesController < Profiles::ApplicationController end def update - user_params.except!(:email) if @user.ldap_user? + user_params.except!(:email) if @user.external_email? respond_to do |format| if @user.update_attributes(user_params) @@ -40,6 +40,14 @@ class ProfilesController < Profiles::ApplicationController redirect_to profile_account_path end + def reset_rss_token + if current_user.reset_rss_token! + flash[:notice] = "RSS token was successfully reset" + end + + redirect_to profile_account_path + end + def audit_log @events = AuditEvent.where(entity_type: "User", entity_id: current_user.id). order("created_at DESC"). @@ -68,7 +76,7 @@ class ProfilesController < Profiles::ApplicationController end def user_params - params.require(:user).permit( + @user_params ||= params.require(:user).permit( :avatar, :bio, :email, diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 1224e9503c9..ea036b1f705 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -27,7 +27,7 @@ class Projects::ArtifactsController < Projects::ApplicationController def file blob = @entry.blob - override_max_blob_size(blob) + conditionally_expand_blob(blob) respond_to do |format| format.html do @@ -46,7 +46,7 @@ class Projects::ArtifactsController < Projects::ApplicationController def keep build.keep_artifacts! - redirect_to namespace_project_build_path(project.namespace, project, build) + redirect_to namespace_project_job_path(project.namespace, project, build) end def latest_succeeded @@ -79,7 +79,7 @@ class Projects::ArtifactsController < Projects::ApplicationController end def build_from_id - project.builds.find_by(id: params[:build_id]) if params[:build_id] + project.builds.find_by(id: params[:job_id]) if params[:job_id] end def build_from_ref diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb index 53788687076..21a403f3765 100644 --- a/app/controllers/projects/avatars_controller.rb +++ b/app/controllers/projects/avatars_controller.rb @@ -21,6 +21,6 @@ class Projects::AvatarsController < Projects::ApplicationController @project.save - redirect_to edit_project_path(@project) + redirect_to edit_project_path(@project), status: 302 end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 9489bbddfc4..7025c7a1de6 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -35,13 +35,15 @@ class Projects::BlobController < Projects::ApplicationController end def show - override_max_blob_size(@blob) + conditionally_expand_blob(@blob) respond_to do |format| format.html do environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path) + render 'show' end diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb index 67e3c9add81..ad53bb749a0 100644 --- a/app/controllers/projects/boards/lists_controller.rb +++ b/app/controllers/projects/boards/lists_controller.rb @@ -5,7 +5,9 @@ module Projects before_action :authorize_read_list!, only: [:index] def index - render json: serialize_as_json(board.lists) + lists = ::Boards::Lists::ListService.new(project, current_user).execute(board) + + render json: serialize_as_json(lists) end def create diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 79de0d15642..55de70b3026 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -74,10 +74,16 @@ class Projects::BranchesController < Projects::ApplicationController def destroy @branch_name = Addressable::URI.unescape(params[:id]) result = DeleteBranchService.new(project, current_user).execute(@branch_name) +<<<<<<< HEAD +======= + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 respond_to do |format| format.html do - redirect_to namespace_project_branches_path(@project.namespace, - @project), status: 303 + flash_type = result[:status] == :error ? :alert : :notice + flash[flash_type] = result[:message] + + redirect_to namespace_project_branches_path(@project.namespace, @project), status: 303 end format.js { render nothing: true, status: result[:return_code] } diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb new file mode 100644 index 00000000000..f34a198634e --- /dev/null +++ b/app/controllers/projects/build_artifacts_controller.rb @@ -0,0 +1,55 @@ +class Projects::BuildArtifactsController < Projects::ApplicationController + include ExtractsPath + include RendersBlob + + before_action :authorize_read_build! + before_action :extract_ref_name_and_path + before_action :validate_artifacts! + + def download + redirect_to download_namespace_project_job_artifacts_path(project.namespace, project, job) + end + + def browse + redirect_to browse_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path]) + end + + def file + redirect_to file_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path]) + end + + def raw + redirect_to raw_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path]) + end + + def latest_succeeded + redirect_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, job, ref_name_and_path: params[:ref_name_and_path], job: params[:job]) + end + + private + + def validate_artifacts! + render_404 unless job && job.artifacts? + end + + 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 job + @job ||= job_from_id || job_from_ref + end + + def job_from_id + project.builds.find_by(id: params[:build_id]) if params[:build_id] + end + + def job_from_ref + return unless @ref_name + + jobs = project.latest_successful_builds_for(@ref_name) + jobs.find_by(name: params[:job]) + end +end diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index dfaaea71b9c..f1af259e524 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -1,47 +1,12 @@ class Projects::BuildsController < Projects::ApplicationController - before_action :build, except: [:index, :cancel_all] - - before_action :authorize_read_build!, - only: [:index, :show, :status, :raw, :trace] - before_action :authorize_update_build!, - except: [:index, :show, :status, :raw, :trace, :cancel_all] - - layout 'project' + before_action :authorize_read_build! def index - @scope = params[:scope] - @all_builds = project.builds.relevant - @builds = @all_builds.order('created_at DESC') - @builds = - case @scope - when 'pending' - @builds.pending.reverse_order - when 'running' - @builds.running.reverse_order - when 'finished' - @builds.finished - else - @builds - end - @builds = @builds.includes([ - { pipeline: :project }, - :project, - :tags - ]) - @builds = @builds.page(params[:page]).per(30) - end - - def cancel_all - return access_denied! unless can?(current_user, :update_build, project) - - @project.builds.running_or_pending.each do |build| - build.cancel if can?(current_user, :update_build, build) - end - - redirect_to namespace_project_builds_path(project.namespace, project) + redirect_to namespace_project_jobs_path(project.namespace, project) end def show +<<<<<<< HEAD @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') @builds = @builds.where("id not in (?)", @build.id) @pipeline = @build.pipeline @@ -102,30 +67,18 @@ class Projects::BuildsController < Projects::ApplicationController else respond_422 end +======= + redirect_to namespace_project_job_path(project.namespace, project, job) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end def raw - build.trace.read do |stream| - if stream.file? - send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' - else - render_404 - end - end + redirect_to raw_namespace_project_job_path(project.namespace, project, job) end private - def authorize_update_build! - return access_denied! unless can?(current_user, :update_build, build) - end - - def build - @build ||= project.builds.find(params[:id]) - .present(current_user: current_user) - end - - def build_path(build) - namespace_project_build_path(build.project.namespace, build.project, build) + def job + @job ||= project.builds.find(params[:id]) end end diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 008d2f5815f..88dd600e5fe 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -51,13 +51,9 @@ class Projects::CompareController < Projects::ApplicationController if @compare @commits = @compare.commits - @start_commit = @compare.start_commit - @commit = @compare.commit - @base_commit = @compare.base_commit - @diffs = @compare.diffs(diff_options) - environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @commit } + environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @compare.commit } @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last @diff_notes_disabled = true diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index f27089b8590..7f1469e107d 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -4,6 +4,7 @@ class Projects::DeployKeysController < Projects::ApplicationController # Authorize before_action :authorize_admin_project! + before_action :authorize_update_deploy_key!, only: [:edit, :update] layout "project_settings" @@ -21,7 +22,7 @@ class Projects::DeployKeysController < Projects::ApplicationController end def create - @key = DeployKey.new(deploy_key_params.merge(user: current_user)) + @key = DeployKey.new(create_params.merge(user: current_user)) unless @key.valid? && @project.deploy_keys << @key flash[:alert] = @key.errors.full_messages.join(', ').html_safe @@ -29,6 +30,18 @@ class Projects::DeployKeysController < Projects::ApplicationController redirect_to_repository_settings(@project) end + def edit + end + + def update + if deploy_key.update_attributes(update_params) + flash[:notice] = 'Deploy key was successfully updated.' + redirect_to_repository_settings(@project) + else + render 'edit' + end + end + def enable Projects::EnableDeployKeyService.new(@project, current_user, params).execute @@ -52,7 +65,19 @@ class Projects::DeployKeysController < Projects::ApplicationController protected - def deploy_key_params + def deploy_key + @deploy_key ||= @project.deploy_keys.find(params[:id]) + end + + def create_params params.require(:deploy_key).permit(:key, :title, :can_push) end + + def update_params + params.require(:deploy_key).permit(:title, :can_push) + end + + def authorize_update_deploy_key! + access_denied! unless can?(current_user, :update_deploy_key, deploy_key) + end end diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb index f06a4d943f3..1648d458abd 100644 --- a/app/controllers/projects/deployments_controller.rb +++ b/app/controllers/projects/deployments_controller.rb @@ -11,13 +11,23 @@ class Projects::DeploymentsController < Projects::ApplicationController end def metrics +<<<<<<< HEAD @metrics = deployment.metrics(1.hour) +======= + return render_404 unless deployment.has_metrics? + @metrics = deployment.metrics +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 if @metrics&.any? render json: @metrics, status: :ok else head :no_content end +<<<<<<< HEAD +======= + rescue NotImplementedError + render_404 +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end private diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index fd57afbd05f..4630f451445 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -15,6 +15,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController respond_to do |format| format.html format.json do + Gitlab::PollingInterval.set_header(response, interval: 3_000) + render json: { environments: EnvironmentSerializer .new(project: @project, current_user: @current_user) @@ -31,6 +33,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController def folder folder_environments = project.environments.where(environment_type: params[:id]) @environments = folder_environments.with_state(params[:scope] || :available) + .order(:name) respond_to do |format| format.html diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index 9a1bf037a95..7f3205a8001 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -128,32 +128,10 @@ class Projects::GitHttpClientController < Projects::ApplicationController @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 + @authentication_result.success? end def ci? authentication_result.ci?(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 end diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 9e4edcae101..b6b62da7b60 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -1,38 +1,27 @@ class Projects::GitHttpController < Projects::GitHttpClientController include WorkhorseRequest + before_action :access_check + + rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403 + rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404 + # GET /foo/bar.git/info/refs?service=git-upload-pack (git pull) # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) def info_refs - if upload_pack? && upload_pack_allowed? - log_user_activity - - render_ok - elsif receive_pack? && receive_pack_allowed? - render_ok - elsif http_blocked? - render_http_not_allowed - else - render_denied - end + log_user_activity if upload_pack? + + render_ok end # POST /foo/bar.git/git-upload-pack (git pull) def git_upload_pack - if upload_pack? && upload_pack_allowed? - render_ok - else - render_denied - end + render_ok end # POST /foo/bar.git/git-receive-pack" (git push) def git_receive_pack - if receive_pack? && receive_pack_allowed? - render_ok - else - render_denied - end + render_ok end private @@ -45,10 +34,6 @@ class Projects::GitHttpController < Projects::GitHttpClientController git_command == 'git-upload-pack' end - def receive_pack? - git_command == 'git-receive-pack' - end - def git_command if action_name == 'info_refs' params[:service] @@ -62,47 +47,27 @@ class Projects::GitHttpController < Projects::GitHttpClientController render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name) end - def render_http_not_allowed - render plain: access_check.message, status: :forbidden + def render_403(exception) + render plain: exception.message, status: :forbidden end - def render_denied - if user && can?(user, :read_project, project) - render plain: access_denied_message, status: :forbidden - else - # Do not leak information about project existence - render_not_found - end - end - - def access_denied_message - 'Access denied' + def render_404(exception) + render plain: exception.message, status: :not_found end - def upload_pack_allowed? - return false unless Gitlab.config.gitlab_shell.upload_pack - - access_check.allowed? || ci? + def access + @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities) end - def access - @access ||= access_klass.new(user, project, 'http', authentication_abilities: authentication_abilities) + def access_actor + return user if user + return :ci if ci? end 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? - !access.protocol_allowed? - end - - def receive_pack_allowed? - return false unless Gitlab.config.gitlab_shell.receive_pack - - access_check.allowed? + access.check(git_command, '_any') end def access_klass diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index 66b7bdbd988..deb33a2f0ff 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -36,7 +36,7 @@ class Projects::GroupLinksController < Projects::ApplicationController respond_to do |format| format.html do - redirect_to namespace_project_settings_members_path(project.namespace, project) + redirect_to namespace_project_settings_members_path(project.namespace, project), status: 302 end format.js { head :ok } end diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb new file mode 100644 index 00000000000..354f0d6db3a --- /dev/null +++ b/app/controllers/projects/hook_logs_controller.rb @@ -0,0 +1,33 @@ +class Projects::HookLogsController < Projects::ApplicationController + include HooksExecution + + before_action :authorize_admin_project! + + before_action :hook, only: [:show, :retry] + before_action :hook_log, only: [:show, :retry] + + respond_to :html + + layout 'project_settings' + + def show + end + + def retry + status, message = hook.execute(hook_log.request_data, hook_log.trigger) + + set_hook_execution_notice(status, message) + + redirect_to edit_namespace_project_hook_path(@project.namespace, @project, @hook) + end + + private + + def hook + @hook ||= @project.hooks.find(params[:hook_id]) + end + + def hook_log + @hook_log ||= hook.web_hook_logs.find(params[:id]) + end +end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index 86d13a0d222..f5143280154 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -1,7 +1,9 @@ class Projects::HooksController < Projects::ApplicationController + include HooksExecution + # Authorize before_action :authorize_admin_project! - before_action :hook, only: :edit + before_action :hook_logs, only: :edit respond_to :html @@ -34,13 +36,7 @@ class Projects::HooksController < Projects::ApplicationController if !@project.empty_repo? status, message = TestHookService.new.execute(hook, current_user) - if status && status >= 200 && status < 400 - flash[:notice] = "Hook executed successfully: HTTP #{status}" - elsif status - flash[:alert] = "Hook executed successfully but returned HTTP #{status} #{message}" - else - flash[:alert] = "Hook execution failed: #{message}" - end + set_hook_execution_notice(status, message) else flash[:alert] = 'Hook execution failed. Ensure the project has commits.' end @@ -51,7 +47,7 @@ class Projects::HooksController < Projects::ApplicationController def destroy hook.destroy - redirect_to namespace_project_settings_integrations_path(@project.namespace, @project) + redirect_to namespace_project_settings_integrations_path(@project.namespace, @project), status: 302 end private @@ -60,6 +56,11 @@ class Projects::HooksController < Projects::ApplicationController @hook ||= @project.hooks.find(params[:id]) end + def hook_logs + @hook_logs ||= + Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page]) + end + def hook_params params.require(:hook).permit( :job_events, diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index a1b84afcd91..4b143434ea5 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -14,14 +14,7 @@ class Projects::ImportsController < Projects::ApplicationController @project.import_url = params[:project][:import_url] if @project.save - @project.reload - - if @project.import_failed? - @project.import_retry - else - @project.import_start - @project.add_import_job - end + @project.reload.import_schedule end redirect_to namespace_project_import_path(@project.namespace, @project) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 7b1e4a70232..9073ef21d7e 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -11,10 +11,10 @@ class Projects::IssuesController < Projects::ApplicationController before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, - :related_branches, :can_create_branch, :rendered_title, :create_merge_request] + :related_branches, :can_create_branch, :realtime_changes, :create_merge_request] # Allow read any issue - before_action :authorize_read_issue!, only: [:show, :rendered_title] + before_action :authorize_read_issue!, only: [:show, :realtime_changes] # Allow write(create) issue before_action :authorize_create_issue!, only: [:new, :create] @@ -148,10 +148,7 @@ class Projects::IssuesController < Projects::ApplicationController format.json do if @issue.valid? - render json: @issue.to_json(methods: [:task_status, :task_status_short], - include: { milestone: {}, - assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, - labels: { methods: :text_color } }) + render json: IssueSerializer.new.represent(@issue) else render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity end @@ -199,7 +196,7 @@ class Projects::IssuesController < Projects::ApplicationController end end - def rendered_title + def realtime_changes Gitlab::PollingInterval.set_header(response, interval: 3_000) response = { @@ -207,8 +204,12 @@ class Projects::IssuesController < Projects::ApplicationController title_text: @issue.title, description: view_context.markdown_field(@issue, :description), description_text: @issue.description, +<<<<<<< HEAD task_status: @issue.task_status, issue_number: @issue.iid, +======= + task_status: @issue.task_status +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } if @issue.is_edited? @@ -234,7 +235,7 @@ class Projects::IssuesController < Projects::ApplicationController def issue # 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 + @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take! end alias_method :subscribable_resource, :issue alias_method :issuable, :issue @@ -273,25 +274,10 @@ class Projects::IssuesController < Projects::ApplicationController end end - # Since iids are implemented only in 6.1 - # user may navigate to issue page using old global ids. - # - # To prevent 404 errors we provide a redirect to correct iids until 7.0 release - # - def redirect_old - issue = @project.issues.find_by(id: params[:id]) - - if issue - redirect_to issue_path(issue) - else - raise ActiveRecord::RecordNotFound.new - end - end - def issue_params params.require(:issue).permit( :title, :assignee_id, :position, :description, :confidential, - :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [], + :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [] ) end @@ -300,7 +286,10 @@ class Projects::IssuesController < Projects::ApplicationController notice = "Please sign in to create the new issue." - store_location_for :user, request.fullpath + if request.get? && !request.xhr? + store_location_for :user, request.fullpath + end + redirect_to new_user_session_path, notice: notice end end diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb new file mode 100644 index 00000000000..cb4f46388fd --- /dev/null +++ b/app/controllers/projects/jobs_controller.rb @@ -0,0 +1,142 @@ +class Projects::JobsController < Projects::ApplicationController + before_action :build, except: [:index, :cancel_all] + + before_action :authorize_read_build!, + only: [:index, :show, :status, :raw, :trace] + before_action :authorize_update_build!, + except: [:index, :show, :status, :raw, :trace, :cancel_all] + + layout 'project' + + def index + @scope = params[:scope] + @all_builds = project.builds.relevant + @builds = @all_builds.order('created_at DESC') + @builds = + case @scope + when 'pending' + @builds.pending.reverse_order + when 'running' + @builds.running.reverse_order + when 'finished' + @builds.finished + else + @builds + end + @builds = @builds.includes([ + { pipeline: :project }, + :project, + :tags + ]) + @builds = @builds.page(params[:page]).per(30) + end + + def cancel_all + return access_denied! unless can?(current_user, :update_build, project) + + @project.builds.running_or_pending.each do |build| + build.cancel if can?(current_user, :update_build, build) + end + + redirect_to namespace_project_jobs_path(project.namespace, project) + end + + def show + @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') + @builds = @builds.where("id not in (?)", @build.id) + @pipeline = @build.pipeline + + respond_to do |format| + format.html + format.json do + Gitlab::PollingInterval.set_header(response, interval: 10_000) + + render json: BuildSerializer + .new(project: @project, current_user: @current_user) + .represent(@build, {}, BuildDetailsEntity) + end + end + end + + def trace + build.trace.read do |stream| + respond_to do |format| + format.json do + result = { + id: @build.id, status: @build.status, complete: @build.complete? + } + + if stream.valid? + stream.limit + state = params[:state].presence + trace = stream.html_with_state(state) + result.merge!(trace.to_h) + end + + render json: result + end + end + end + end + + def retry + return respond_422 unless @build.retryable? + + build = Ci::Build.retry(@build, current_user) + redirect_to build_path(build) + end + + def play + return respond_422 unless @build.playable? + + build = @build.play(current_user) + redirect_to build_path(build) + end + + def cancel + return respond_422 unless @build.cancelable? + + @build.cancel + redirect_to build_path(@build) + end + + def status + render json: BuildSerializer + .new(project: @project, current_user: @current_user) + .represent_status(@build) + end + + def erase + if @build.erase(erased_by: current_user) + redirect_to namespace_project_job_path(project.namespace, project, @build), + notice: "Build has been successfully erased!" + else + respond_422 + end + end + + def raw + build.trace.read do |stream| + if stream.file? + send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' + else + render_404 + end + end + end + + private + + def authorize_update_build! + return access_denied! unless can?(current_user, :update_build, build) + end + + def build + @build ||= project.builds.find(params[:id]) + .present(current_user: current_user) + end + + def build_path(build) + namespace_project_job_path(build.project.namespace, build.project, build) + end +end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 71bfb7163da..ac151839f61 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -74,7 +74,9 @@ class Projects::LabelsController < Projects::ApplicationController @label.destroy @labels = find_labels - redirect_to(namespace_project_labels_path(@project.namespace, @project), notice: 'Label was removed') + redirect_to namespace_project_labels_path(@project.namespace, @project), + status: 302, + notice: 'Label was removed' end def remove_priority diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index 8a5a645ed0e..1b0d3aab3fa 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -22,7 +22,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController 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", + documentation_url: "#{Gitlab.config.gitlab.url}/help" }, status: 501 ) @@ -55,7 +55,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController else object[:error] = { code: 404, - message: "Object does not exist on the server or you don't have permissions to access it", + message: "Object does not exist on the server or you don't have permissions to access it" } end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 0352065998b..1075bdf9301 100755..100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -14,7 +14,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController ] before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines] before_action :define_show_vars, only: [:diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines] +<<<<<<< HEAD before_action :define_commit_vars, only: [:diffs] +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines] before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines] before_action :check_if_can_be_merged, only: :show @@ -130,8 +133,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController @diff_notes_disabled = true end - define_commit_vars - render_diff_for_path(@diffs) end @@ -500,11 +501,14 @@ class Projects::MergeRequestsController < Projects::ApplicationController @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end +<<<<<<< HEAD def define_commit_vars @commit = @merge_request.diff_head_commit @base_commit = @merge_request.diff_base_commit || @merge_request.likely_diff_base_commit end +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 def define_diff_vars @merge_request_diff = if params[:diff_id] @@ -569,7 +573,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController @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 @note_counts = Note.where(commit_id: @commits.map(&:id)). group(:commit_id).count diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index c56bce19eee..ae16f69955a 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -80,7 +80,7 @@ class Projects::MilestonesController < Projects::ApplicationController Milestones::DestroyService.new(project, current_user).execute(milestone) respond_to do |format| - format.html { redirect_to namespace_project_milestones_path } + format.html { redirect_to namespace_project_milestones_path, status: 302 } format.js { head :ok } end end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 93b2c180810..28b383e69eb 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -15,8 +15,9 @@ class Projects::PagesController < Projects::ApplicationController respond_to do |format| format.html do - redirect_to(namespace_project_pages_path(@project.namespace, @project), - notice: 'Pages were removed') + redirect_to namespace_project_pages_path(@project.namespace, @project), + status: 302, + notice: 'Pages were removed' end end end diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index 3a93977fd27..dbd011f6c5d 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -27,8 +27,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController respond_to do |format| format.html do - redirect_to(namespace_project_pages_path(@project.namespace, @project), - notice: 'Domain was removed') + redirect_to namespace_project_pages_path(@project.namespace, @project), + status: 302, + notice: 'Domain was removed' end format.js end diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 1616b2cb6b8..2662a146968 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -49,9 +49,11 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController def destroy if schedule.destroy - redirect_to pipeline_schedules_path(@project) + redirect_to pipeline_schedules_path(@project), status: 302 else - redirect_to pipeline_schedules_path(@project), alert: "Failed to remove the pipeline schedule" + redirect_to pipeline_schedules_path(@project), + status: 302, + alert: "Failed to remove the pipeline schedule" end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 7fe3c3c116c..6223e7943f8 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -44,7 +44,7 @@ class Projects::PipelinesController < Projects::ApplicationController all: @pipelines_count, running: @running_count, pending: @pending_count, - finished: @finished_count, + finished: @finished_count } } end @@ -58,7 +58,7 @@ class Projects::PipelinesController < Projects::ApplicationController def create @pipeline = Ci::CreatePipelineService .new(project, current_user, create_params) - .execute(ignore_skip_ci: true, save_on_errors: false) + .execute(:web, ignore_skip_ci: true, save_on_errors: false) if @pipeline.persisted? redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) @@ -99,7 +99,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def stage - @stage = pipeline.stage(params[:stage]) + @stage = pipeline.legacy_stage(params[:stage]) return not_found unless @stage respond_to do |format| diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index ff50602831c..38a47651000 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -7,7 +7,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def update if @project.update_attributes(update_params) - flash[:notice] = "CI/CD Pipelines settings for '#{@project.name}' were successfully updated." + flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) else render 'show' diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index ba24fa9acfe..d1719f12072 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -19,7 +19,7 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController def protected_ref_params params.require(:protected_branch).permit(:name, - merge_access_levels_attributes: [:access_level, :id], - push_access_levels_attributes: [:access_level, :id]) + merge_access_levels_attributes: access_level_attributes, + push_access_levels_attributes: access_level_attributes) end end diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb index 083a70968e5..b51bdf7aa78 100644 --- a/app/controllers/projects/protected_refs_controller.rb +++ b/app/controllers/projects/protected_refs_controller.rb @@ -44,4 +44,10 @@ class Projects::ProtectedRefsController < Projects::ApplicationController format.js { head :ok } end end + + protected + + def access_level_attributes + %i(access_level id) + end end diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb index c61ddf145e6..a5dbd7e46ae 100644 --- a/app/controllers/projects/protected_tags_controller.rb +++ b/app/controllers/projects/protected_tags_controller.rb @@ -18,6 +18,6 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController end def protected_ref_params - params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id]) + params.require(:protected_tag).permit(:name, create_access_levels_attributes: access_level_attributes) end end diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 667f4870c7a..2a0b58fae7c 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -74,6 +74,6 @@ class Projects::RefsController < Projects::ApplicationController private def validate_ref_id - return not_found! if params[:id].present? && params[:id] !~ Gitlab::Regex.git_reference_regex + return not_found! if params[:id].present? && params[:id] !~ Gitlab::PathRegex.git_reference_regex end end diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 17f391ba07f..98e78585be8 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -11,9 +11,11 @@ module Projects def destroy if image.destroy redirect_to project_container_registry_path(@project), + status: 302, notice: 'Image repository has been removed successfully!' else redirect_to project_container_registry_path(@project), + status: 302, alert: 'Failed to remove image repository!' end end diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb index d689cade3ab..5050dba3aab 100644 --- a/app/controllers/projects/registry/tags_controller.rb +++ b/app/controllers/projects/registry/tags_controller.rb @@ -6,9 +6,11 @@ module Projects def destroy if tag.delete redirect_to project_container_registry_path(@project), + status: 302, notice: 'Registry tag has been removed successfully!' else redirect_to project_container_registry_path(@project), + status: 302, alert: 'Failed to remove registry tag!' end end diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb index 8267b14941d..3cb01405b05 100644 --- a/app/controllers/projects/runner_projects_controller.rb +++ b/app/controllers/projects/runner_projects_controller.rb @@ -22,6 +22,6 @@ class Projects::RunnerProjectsController < Projects::ApplicationController runner_project = project.runner_projects.find(params[:id]) runner_project.destroy - redirect_to runners_path(project) + redirect_to runners_path(project), status: 302 end end diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index 8b50ea207a5..160e632648a 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -24,7 +24,7 @@ class Projects::RunnersController < Projects::ApplicationController @runner.destroy end - redirect_to runners_path(@project) + redirect_to runners_path(@project), status: 302 end def resume diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index f9d798d0455..704f8cc8a79 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -4,6 +4,7 @@ class Projects::ServicesController < Projects::ApplicationController # Authorize before_action :authorize_admin_project! before_action :service, only: [:edit, :update, :test] + before_action :update_service, only: [:update, :test] respond_to :html @@ -13,36 +14,46 @@ class Projects::ServicesController < Projects::ApplicationController end def update - @service.assign_attributes(service_params[:service]) if @service.save(context: :manual_change) - redirect_to( - edit_namespace_project_service_path(@project.namespace, @project, @service.to_param), - notice: 'Successfully updated.' - ) + redirect_to(namespace_project_settings_integrations_path(@project.namespace, @project), notice: success_message) else render 'edit' end end def test - return render_404 unless @service.can_test? + message = {} + + if @service.can_test? + data = @service.test_data(project, current_user) + outcome = @service.test(data) - data = @service.test_data(project, current_user) - outcome = @service.test(data) + unless outcome[:success] + message = { error: true, message: 'Test failed.', service_response: outcome[:result].to_s } + end - if outcome[:success] - message = { notice: 'We sent a request to the provided URL' } + status = :ok else - error_message = "We tried to send a request to the provided URL but an error occurred" - error_message << ": #{outcome[:result]}" if outcome[:result].present? - message = { alert: error_message } + status = :not_found end - redirect_back_or_default(options: message) + render json: message, status: status end private + def success_message + if @service.active? + "#{@service.title} activated." + else + "#{@service.title} settings saved, but not activated." + end + end + + def update_service + @service.assign_attributes(service_params[:service]) + end + def service @service ||= @project.find_or_initialize_service(params[:id]) end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index 3b2b0d9e502..8a8f8d6a27d 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -56,7 +56,7 @@ class Projects::SnippetsController < Projects::ApplicationController def show blob = @snippet.blob - override_max_blob_size(blob) + conditionally_expand_blob(blob) respond_to do |format| format.html do @@ -79,7 +79,7 @@ class Projects::SnippetsController < Projects::ApplicationController @snippet.destroy - redirect_to namespace_project_snippets_path(@project.namespace, @project) + redirect_to namespace_project_snippets_path(@project.namespace, @project), status: 302 end protected @@ -107,6 +107,6 @@ class Projects::SnippetsController < Projects::ApplicationController end def snippet_params - params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level) + params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description) end end diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index e13f0bde315..afbea3e2b40 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -38,6 +38,8 @@ class Projects::TagsController < Projects::ApplicationController redirect_to namespace_project_tag_path(@project.namespace, @project, @tag.name) else @error = result[:message] + @message = params[:message] + @release_description = params[:release_description] render action: 'new' end end @@ -48,7 +50,7 @@ class Projects::TagsController < Projects::ApplicationController respond_to do |format| if result[:status] == :success format.html do - redirect_to namespace_project_tags_path(@project.namespace, @project) + redirect_to namespace_project_tags_path(@project.namespace, @project), status: 303 end format.js @@ -57,7 +59,7 @@ class Projects::TagsController < Projects::ApplicationController format.html do redirect_to namespace_project_tags_path(@project.namespace, @project), - alert: @error + alert: @error, status: 303 end format.js do diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 5e2182c883e..f8eb8e00a5d 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -24,6 +24,8 @@ class Projects::TreeController < Projects::ApplicationController end end + @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit + respond_to do |format| format.html # Disable cache so browser history works @@ -48,7 +50,7 @@ class Projects::TreeController < Projects::ApplicationController @dir_name = File.join(@path, params[:dir_name]) @commit_params = { file_path: @dir_name, - commit_message: params[:commit_message], + commit_message: params[:commit_message] } end end diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb index afa56de920b..e86adddd77f 100644 --- a/app/controllers/projects/triggers_controller.rb +++ b/app/controllers/projects/triggers_controller.rb @@ -50,7 +50,7 @@ class Projects::TriggersController < Projects::ApplicationController flash[:alert] = "Could not remove the trigger." end - redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project), status: 302 end private diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index a4d1b1ee69b..50e25a00f03 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -36,12 +36,15 @@ class Projects::VariablesController < Projects::ApplicationController @key = @project.variables.find(params[:id]) @key.destroy - redirect_to namespace_project_settings_ci_cd_path(project.namespace, project), notice: 'Variable was successfully removed.' + redirect_to namespace_project_settings_ci_cd_path(project.namespace, project), + status: 302, + notice: 'Variable was successfully removed.' end private def project_params - params.require(:variable).permit([:id, :key, :value, :_destroy]) + params.require(:variable) + .permit([:id, :key, :value, :protected, :_destroy]) end end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 887d18dbec3..e54b90b8d52 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -85,10 +85,9 @@ class Projects::WikisController < Projects::ApplicationController @page = @project_wiki.find_page(params[:id]) WikiPages::DestroyService.new(@project, current_user).execute(@page) - redirect_to( - namespace_project_wiki_path(@project.namespace, @project, :home), - notice: "Page was successfully deleted" - ) + redirect_to namespace_project_wiki_path(@project.namespace, @project, :home), + status: 302, + notice: "Page was successfully deleted" end def git_access diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index ed1c9f1b620..38ed7d776a7 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -34,7 +34,7 @@ class ProjectsController < Projects::ApplicationController redirect_to( project_path(@project), - notice: "Project '#{@project.name}' was successfully created." + notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name } ) else render 'new' @@ -49,7 +49,7 @@ class ProjectsController < Projects::ApplicationController respond_to do |format| if result[:status] == :success - flash[:notice] = "Project '#{@project.name}' was successfully updated." + flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name } format.html do redirect_to(edit_project_path(@project)) end @@ -76,7 +76,7 @@ class ProjectsController < Projects::ApplicationController return access_denied! unless can?(current_user, :remove_fork_project, @project) if ::Projects::UnlinkForkService.new(@project, current_user).execute - flash[:notice] = 'The fork relationship has been removed.' + flash[:notice] = _('The fork relationship has been removed.') end end @@ -97,7 +97,7 @@ class ProjectsController < Projects::ApplicationController end if @project.pending_delete? - flash[:alert] = "Project #{@project.name} queued for deletion." + flash[:alert] = _("Project '%{project_name}' queued for deletion.") % { project_name: @project.name } end respond_to do |format| @@ -117,11 +117,11 @@ class ProjectsController < Projects::ApplicationController return access_denied! unless can?(current_user, :remove_project, @project) ::Projects::DestroyService.new(@project, current_user, {}).async_execute - flash[:alert] = "Project '#{@project.name_with_namespace}' will be deleted." + flash[:alert] = _("Project '%{project_name}' will be deleted.") % { project_name: @project.name_with_namespace } - redirect_to dashboard_projects_path + redirect_to dashboard_projects_path, status: 302 rescue Projects::DestroyService::DestroyError => ex - redirect_to edit_project_path(@project), alert: ex.message + redirect_to edit_project_path(@project), status: 302, alert: ex.message end def new_issue_address @@ -156,7 +156,7 @@ class ProjectsController < Projects::ApplicationController redirect_to( project_path(@project), - notice: "Housekeeping successfully started" + notice: _("Housekeeping successfully started") ) rescue ::Projects::HousekeepingService::LeaseTaken => ex redirect_to( @@ -170,7 +170,7 @@ class ProjectsController < Projects::ApplicationController redirect_to( edit_project_path(@project), - notice: "Project export started. A download link will be sent by email." + notice: _("Project export started. A download link will be sent by email.") ) end @@ -182,16 +182,16 @@ class ProjectsController < Projects::ApplicationController else redirect_to( edit_project_path(@project), - alert: "Project export link has expired. Please generate a new export from your project settings." + alert: _("Project export link has expired. Please generate a new export from your project settings.") ) end end def remove_export if @project.remove_exports - flash[:notice] = "Project export has been deleted." + flash[:notice] = _("Project export has been deleted.") else - flash[:alert] = "Project export could not be deleted." + flash[:alert] = _("Project export could not be deleted.") end redirect_to(edit_project_path(@project)) end @@ -202,7 +202,7 @@ class ProjectsController < Projects::ApplicationController else redirect_to( edit_project_path(@project), - alert: "Project export could not be deleted." + alert: _("Project export could not be deleted.") ) end end @@ -220,13 +220,13 @@ class ProjectsController < Projects::ApplicationController branches = BranchesFinder.new(@repository, params).execute.map(&:name) options = { - 'Branches' => branches.take(100), + s_('RefSwitcher|Branches') => branches.take(100) } unless @repository.tag_count.zero? tags = TagsFinder.new(@repository, params).execute.map(&:name) - options['Tags'] = tags.take(100) + options[s_('RefSwitcher|Tags')] = tags.take(100) end # If reference is commit id - we should add it to branch/tag selectbox @@ -257,7 +257,7 @@ class ProjectsController < Projects::ApplicationController # # pages list order: repository readme, wiki home, issues list, customize workflow def render_landing_page - if @project.feature_available?(:repository, current_user) + if can?(current_user, :download_code, @project) return render 'projects/no_repo' unless @project.repository_exists? render 'projects/empty' if @project.empty_repo? else diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 3ca14dee33c..1bc6520370a 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -25,12 +25,12 @@ class RegistrationsController < Devise::RegistrationsController end def destroy - DeleteUserWorker.perform_async(current_user.id, current_user.id) + current_user.delete_async(deleted_by: current_user) respond_to do |format| format.html do session.try(:destroy) - redirect_to new_user_session_path, notice: "Account scheduled for removal." + redirect_to new_user_session_path, status: 302, notice: "Account scheduled for removal." end end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 8c6ba4915cd..d7c702b94f8 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -47,6 +47,10 @@ class SessionsController < Devise::SessionsController private + def login_counter + @login_counter ||= Gitlab::Metrics.counter(:user_session_logins, 'User sign in count') + end + # Handle an "initial setup" state, where there's only one user, it's an admin, # and they require a password change. def check_initial_setup @@ -90,7 +94,7 @@ class SessionsController < Devise::SessionsController # Prevent a 'you are already signed in' message directly after signing: # we should never redirect to '/users/sign_in' after signing in successfully. - unless redirect_path == new_user_session_path + unless URI(redirect_path).path == new_user_session_path store_location_for(:redirect, redirect_path) end end @@ -103,6 +107,10 @@ class SessionsController < Devise::SessionsController provider = Gitlab.config.omniauth.auto_sign_in_with_provider return unless provider.present? + # If a "auto_sign_in" query parameter is set to a falsy value, don't auto sign-in. + # Otherwise, the default is to auto sign-in. + return if Gitlab::Utils.to_boolean(params[:auto_sign_in]) == false + # Auto sign in with an Omniauth provider only if the standard "you need to sign-in" alert is # registered or no alert at all. In case of another alert (such as a blocked user), it is safer # to do nothing to prevent redirection loops with certain Omniauth providers. @@ -125,6 +133,7 @@ class SessionsController < Devise::SessionsController end def log_user_activity(user) + login_counter.increment Users::ActivityService.new(user, 'login').execute end diff --git a/app/controllers/sherlock/transactions_controller.rb b/app/controllers/sherlock/transactions_controller.rb index ccc739da879..cb6c3a7cd98 100644 --- a/app/controllers/sherlock/transactions_controller.rb +++ b/app/controllers/sherlock/transactions_controller.rb @@ -13,7 +13,7 @@ module Sherlock def destroy_all Gitlab::Sherlock.collection.clear - redirect_to(:back) + redirect_to :back, status: 302 end end end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 7445f61195d..3d86dd2ea2c 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -45,6 +45,8 @@ class SnippetsController < ApplicationController @snippet = CreateSnippetService.new(nil, current_user, create_params).execute + move_temporary_files if @snippet.valid? && params[:files] + recaptcha_check_with_fallback { render :new } end @@ -58,7 +60,7 @@ class SnippetsController < ApplicationController def show blob = @snippet.blob - override_max_blob_size(blob) + conditionally_expand_blob(blob) @note = Note.new(noteable: @snippet) @noteable = @snippet @@ -82,7 +84,7 @@ class SnippetsController < ApplicationController @snippet.destroy - redirect_to snippets_path + redirect_to snippets_path, status: 302 end def preview_markdown @@ -124,6 +126,12 @@ class SnippetsController < ApplicationController end def snippet_params - params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level) + params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description) + end + + def move_temporary_files + params[:files].each do |file| + FileMover.new(file, @snippet).execute + end end end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index eef53730291..dc882b17143 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -9,12 +9,16 @@ class UploadsController < ApplicationController private def find_model + return nil unless params[:id] + return render_404 unless upload_model && upload_mount @model = upload_model.find(params[:id]) end def authorize_access! + return nil unless model + authorized = case model when Note @@ -33,6 +37,8 @@ class UploadsController < ApplicationController end def authorize_create_access! + return nil unless model + # for now we support only personal snippets comments authorized = can?(current_user, :comment_personal_snippet, model) @@ -73,7 +79,12 @@ class UploadsController < ApplicationController def uploader return @uploader if defined?(@uploader) - if model.is_a?(PersonalSnippet) + case model + when nil + @uploader = PersonalFileUploader.new(nil, params[:secret]) + + @uploader.retrieve_from_store!(params[:filename]) + when PersonalSnippet @uploader = PersonalFileUploader.new(model, params[:secret]) @uploader.retrieve_from_store!(params[:filename]) diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb new file mode 100644 index 00000000000..b0450ddc1fd --- /dev/null +++ b/app/finders/events_finder.rb @@ -0,0 +1,62 @@ +class EventsFinder + attr_reader :source, :params, :current_user + + # Used to filter Events + # + # Arguments: + # source - which user or project to looks for events on + # current_user - only return events for projects visible to this user + # params: + # action: string + # target_type: string + # before: datetime + # after: datetime + # + def initialize(params = {}) + @source = params.delete(:source) + @current_user = params.delete(:current_user) + @params = params + end + + def execute + events = source.events + + events = by_current_user_access(events) + events = by_action(events) + events = by_target_type(events) + events = by_created_at_before(events) + events = by_created_at_after(events) + + events + end + + private + + def by_current_user_access(events) + events.merge(ProjectsFinder.new(current_user: current_user).execute).references(:project) + end + + def by_action(events) + return events unless Event::ACTIONS[params[:action]] + + events.where(action: Event::ACTIONS[params[:action]]) + end + + def by_target_type(events) + return events unless Event::TARGET_TYPES[params[:target_type]] + + events.where(target_type: Event::TARGET_TYPES[params[:target_type]]) + end + + def by_created_at_before(events) + return events unless params[:before] + + events.where('events.created_at < ?', params[:before].beginning_of_day) + end + + def by_created_at_after(events) + return events unless params[:after] + + events.where('events.created_at > ?', params[:after].end_of_day) + end +end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index f6d8226bf3f..5bf722d1ec6 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -7,6 +7,7 @@ # project_ids_relation: int[] - project ids to use # params: # trending: boolean +# owned: boolean # non_public: boolean # starred: boolean # sort: string @@ -28,13 +29,17 @@ class ProjectsFinder < UnionFinder def execute items = init_collection - items = by_ids(items) + items = items.map do |item| + item = by_ids(item) + item = by_personal(item) + item = by_starred(item) + item = by_trending(item) + item = by_visibilty_level(item) + item = by_tags(item) + item = by_search(item) + by_archived(item) + end items = union(items) - items = by_personal(items) - items = by_visibilty_level(items) - items = by_tags(items) - items = by_search(items) - items = by_archived(items) sort(items) end @@ -43,10 +48,8 @@ class ProjectsFinder < UnionFinder def init_collection projects = [] - if params[:trending].present? - projects << Project.trending - elsif params[:starred].present? && current_user - projects << current_user.viewable_starred_projects + if params[:owned].present? + projects << current_user.owned_projects if current_user else projects << current_user.authorized_projects if current_user projects << Project.unscoped.public_to_user(current_user) unless params[:non_public].present? @@ -56,7 +59,7 @@ class ProjectsFinder < UnionFinder end def by_ids(items) - project_ids_relation ? items.map { |item| item.where(id: project_ids_relation) } : items + project_ids_relation ? items.where(id: project_ids_relation) : items end def union(items) @@ -67,6 +70,14 @@ class ProjectsFinder < UnionFinder (params[:personal].present? && current_user) ? items.personal(current_user) : items end + def by_starred(items) + (params[:starred].present? && current_user) ? items.starred_by(current_user) : items + end + + def by_trending(items) + params[:trending].present? ? items.trending : items + end + def by_visibilty_level(items) params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index dc13386184e..c358f23f541 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -39,7 +39,7 @@ class TodosFinder private def action_id? - action_id.present? && Todo::ACTION_NAMES.has_key?(action_id.to_i) + action_id.present? && Todo::ACTION_NAMES.key?(action_id.to_i) end def action_id diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb new file mode 100644 index 00000000000..dbd50d1db7c --- /dev/null +++ b/app/finders/users_finder.rb @@ -0,0 +1,74 @@ +# UsersFinder +# +# Used to filter users by set of params +# +# Arguments: +# current_user - which user use +# params: +# username: string +# extern_uid: string +# provider: string +# search: string +# active: boolean +# blocked: boolean +# external: boolean +# +class UsersFinder + attr_accessor :current_user, :params + + def initialize(current_user, params = {}) + @current_user = current_user + @params = params + end + + def execute + users = User.all + users = by_username(users) + users = by_search(users) + users = by_blocked(users) + users = by_active(users) + users = by_external_identity(users) + users = by_external(users) + + users + end + + private + + def by_username(users) + return users unless params[:username] + + users.where(username: params[:username]) + end + + def by_search(users) + return users unless params[:search].present? + + users.search(params[:search]) + end + + def by_blocked(users) + return users unless params[:blocked] + + users.blocked + end + + def by_active(users) + return users unless params[:active] + + users.active + end + + def by_external_identity(users) + return users unless current_user.admin? && params[:extern_uid] && params[:provider] + + users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid])) + end + + def by_external(users) + return users = users.where.not(external: true) unless current_user.admin? + return users unless params[:external] + + users.external + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 8c74d36ad81..71154da7ec5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -77,7 +77,7 @@ module ApplicationHelper end if user - user.avatar_url(size) || default_avatar + user.avatar_url(size: size) || default_avatar else gravatar_icon(user_or_email, size, scale) end @@ -275,7 +275,25 @@ module ApplicationHelper 'active' if condition end - def show_user_callout? - cookies[:user_callout_dismissed] == 'true' + def show_callout?(name) + cookies[name] != 'true' + end + + def linkedin_url(user) + name = user.linkedin + if name =~ %r{\Ahttps?:\/\/(www\.)?linkedin\.com\/in\/} + name + else + "https://www.linkedin.com/in/#{name}" + end + end + + def twitter_url(user) + name = user.twitter + if name =~ %r{\Ahttps?:\/\/(www\.)?twitter\.com\/} + name + else + "https://www.twitter.com/#{name}" + end end end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index b7e0ff8ecd0..bbe7f3c8fb4 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -8,18 +8,28 @@ module AvatarsHelper })) end - def user_avatar(options = {}) + def user_avatar_without_link(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), + avatar_url = options[:url] || avatar_icon(options[:user] || options[:user_email], avatar_size) + data_attributes = { container: 'body' } + + if options[:lazy] + data_attributes[:src] = avatar_url + end + + image_tag( + options[:lazy] ? '' : avatar_url, class: "avatar has-tooltip s#{avatar_size} #{css_class}", alt: "#{user_name}'s avatar", title: user_name, - data: { container: 'body' } + data: data_attributes ) + end + + def user_avatar(options = {}) + avatar = user_avatar_without_link(options) if options[:user] link_to(avatar, user_path(options[:user])) diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index af430270ae4..3efa7c36057 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -18,7 +18,7 @@ module BlobHelper blob = options.delete(:blob) blob ||= project.repository.blob_at(ref, path) rescue nil - return unless blob + return unless blob && blob.readable_text? common_classes = "btn js-edit-blob #{options[:extra_class]}" @@ -120,7 +120,7 @@ module BlobHelper def blob_raw_url if @build && @entry - raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) + raw_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: @entry.path) elsif @snippet if @snippet.project_id raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) @@ -226,7 +226,7 @@ module BlobHelper def open_raw_blob_button(blob) return if blob.empty? - + if blob.raw_binary? || blob.stored_externally? icon = icon('download') title = 'Download' @@ -240,14 +240,10 @@ module BlobHelper def blob_render_error_reason(viewer) case viewer.render_error + when :collapsed + "it is larger than #{number_to_human_size(viewer.collapse_limit)}" when :too_large - max_size = - if viewer.absolutely_too_large? - viewer.absolute_max_size - elsif viewer.too_large? - viewer.max_size - end - "it is larger than #{number_to_human_size(max_size)}" + "it is larger than #{number_to_human_size(viewer.size_limit)}" when :server_side_but_stored_externally case viewer.blob.external_storage when :lfs @@ -264,8 +260,8 @@ module BlobHelper error = viewer.render_error options = [] - if error == :too_large && viewer.can_override_max_size? - options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, override_max_size: true, format: nil))) + if error == :collapsed + options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, expanded: true, format: nil))) end # If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error, @@ -278,4 +274,19 @@ module BlobHelper options end + + def contribution_options(project) + options = [] + + if can?(current_user, :create_issue, project) + options << link_to("submit an issue", new_namespace_project_issue_path(project.namespace, project)) + end + + merge_project = can?(current_user, :create_merge_request, project) ? project : (current_user && current_user.fork_of(project)) + if merge_project + options << link_to("create a merge request", new_namespace_project_merge_request_path(project.namespace, project)) + end + + options + end end diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index b7a28b1b4a7..59519c1335b 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -1,14 +1,4 @@ module BranchesHelper - def can_remove_branch?(project, branch_name) - if ProtectedBranch.protected?(project, branch_name) - false - elsif branch_name == project.repository.root_ref - false - else - can?(current_user, :push_code, project) - end - end - def filter_branches_path(options = {}) exist_opts = { search: params[:search], diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 2eb2c6c7389..f0a0d245dc0 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -2,7 +2,7 @@ module BuildsHelper def build_summary(build, skip: false) if build.has_trace? if skip - link_to "View job trace", pipeline_build_url(build.pipeline, build) + link_to "View job trace", pipeline_job_url(build.pipeline, build) else build.trace.html(last_lines: 10).html_safe end @@ -20,8 +20,8 @@ module BuildsHelper def javascript_build_options { - page_url: namespace_project_build_url(@project.namespace, @project, @build), - build_url: namespace_project_build_url(@project.namespace, @project, @build, :json), + page_url: namespace_project_job_url(@project.namespace, @project, @build), + build_url: namespace_project_job_url(@project.namespace, @project, @build, :json), build_status: @build.status, build_stage: @build.stage, log_state: '' @@ -31,7 +31,7 @@ module BuildsHelper def build_failed_issue_options { title: "Build Failed ##{@build.id}", - description: namespace_project_build_url(@project.namespace, @project, @build) + description: namespace_project_job_url(@project.namespace, @project, @build) } end end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index c85e96cf78d..00464810054 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -42,7 +42,10 @@ module ButtonHelper class: "btn #{css_class}", data: data, type: :button, - title: title + title: title, + aria: { + label: title + } end def http_clone_button(project, placement = 'right', append_link: true) @@ -53,12 +56,12 @@ module ButtonHelper content_tag (append_link ? :a : :span), protocol, class: klass, - href: (project.http_url_to_repo(current_user) if append_link), + href: (project.http_url_to_repo if append_link), data: { html: true, placement: placement, container: 'body', - title: "Set a password on your account<br>to pull or push via #{protocol}" + title: _("Set a password on your account to pull or push via %{protocol}") % { protocol: protocol } } end @@ -73,7 +76,7 @@ module ButtonHelper html: true, placement: placement, container: 'body', - title: 'Add an SSH key to your profile<br>to pull or push via SSH.' + title: _('Add an SSH key to your profile to pull or push via SSH.') } end end diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 32b1e7822af..21c0eb8b54c 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -16,16 +16,18 @@ module CiStatusHelper return status.label end - case status - when 'success' - 'passed' - when 'success_with_warnings' - 'passed with warnings' - when 'manual' - 'waiting for manual action' - else - status - end + label = case status + when 'success' + 'passed' + when 'success_with_warnings' + 'passed with warnings' + when 'manual' + 'waiting for manual action' + else + status + end + translation = "CiStatusLabel|#{label}" + s_(translation) end def ci_text_for_status(status) @@ -35,13 +37,22 @@ module CiStatusHelper case status when 'success' - 'passed' + s_('CiStatusText|passed') when 'success_with_warnings' - 'passed' + s_('CiStatusText|passed') when 'manual' - 'blocked' + s_('CiStatusText|blocked') else - status + # All states are already being translated inside the detailed statuses: + # :running => Gitlab::Ci::Status::Running + # :skipped => Gitlab::Ci::Status::Skipped + # :failed => Gitlab::Ci::Status::Failed + # :success => Gitlab::Ci::Status::Success + # :canceled => Gitlab::Ci::Status::Canceled + # The following states are customized above: + # :manual => Gitlab::Ci::Status::Manual + status_translation = "CiStatusText|#{status}" + s_(status_translation) end end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index cef624430da..5b5cdebe919 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -15,16 +15,6 @@ module CommitsHelper commit_person_link(commit, options.merge(source: :committer)) end - def image_diff_class(diff) - if diff.deleted_file - "deleted" - elsif diff.new_file - "added" - else - nil - end - end - def commit_to_html(commit, ref, project) render 'projects/commits/commit', commit: commit, @@ -74,12 +64,8 @@ module CommitsHelper # Returns the sorted alphabetically links to branches, separated by a comma def commit_branches_links(project, branches) branches.sort.map do |branch| - link_to( - namespace_project_tree_path(project.namespace, project, branch) - ) do - content_tag :span, class: 'label label-gray' do - icon('code-fork') + ' ' + branch - end + link_to(project_ref_path(project, branch), class: "label label-gray ref-name") do + icon('code-fork') + " #{branch}" end end.join(" ").html_safe end @@ -88,29 +74,22 @@ module CommitsHelper def commit_tags_links(project, tags) sorted = VersionSorter.rsort(tags) sorted.map do |tag| - link_to( - namespace_project_commits_path(project.namespace, project, - project.repository.find_tag(tag).name) - ) do - content_tag :span, class: 'label label-gray' do - icon('tag') + ' ' + tag - end + link_to(project_ref_path(project, tag), class: "label label-gray ref-name") do + icon('tag') + " #{tag}" end end.join(" ").html_safe end def link_to_browse_code(project, commit) + return unless current_controller?(:commits) + 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) + elsif @repo.blob_at(commit.id, @path) return link_to( "Browse File", namespace_project_blob_path(project.namespace, project, @@ -200,8 +179,8 @@ module CommitsHelper tree_join(commit_sha, diff_new_path)), class: 'btn view-file js-view-file' ) do - raw('View file @') + content_tag(:span, commit_sha[0..6], - class: 'commit-short-id') + raw('View file @ ') + content_tag(:span, Commit.truncate_sha(commit_sha), + class: 'commit-sha') end end diff --git a/app/helpers/conversational_development_index_helper.rb b/app/helpers/conversational_development_index_helper.rb new file mode 100644 index 00000000000..1ff54415811 --- /dev/null +++ b/app/helpers/conversational_development_index_helper.rb @@ -0,0 +1,16 @@ +module ConversationalDevelopmentIndexHelper + def score_level(score) + if score < 33.33 + 'low' + elsif score < 66.66 + 'average' + else + 'high' + end + end + + def format_score(score) + precision = score < 1 ? 2 : 1 + number_with_precision(score, precision: precision) + end +end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index dc144906548..2ae3a616933 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -8,8 +8,8 @@ module DiffHelper [marked_old_line, marked_new_line] end - def expand_all_diffs? - params[:expand_all_diffs].present? + def diffs_expanded? + params[:expanded].present? end def diff_view @@ -22,10 +22,10 @@ module DiffHelper end def diff_options - options = { ignore_whitespace_change: hide_whitespace?, no_collapse: expand_all_diffs? } + options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded? } if action_name == 'diff_for_path' - options[:no_collapse] = true + options[:expanded] = true options[:paths] = params.values_at(:old_path, :new_path) end @@ -63,15 +63,15 @@ module DiffHelper def parallel_diff_discussions(left, right, diff_file) return unless @grouped_diff_discussions - + discussions_left = discussions_right = nil - if left && (left.unchanged? || left.removed?) + if left && (left.unchanged? || left.discussable?) line_code = diff_file.line_code(left) discussions_left = @grouped_diff_discussions[line_code] end - if right && right.added? + if right&.discussable? line_code = diff_file.line_code(right) discussions_right = @grouped_diff_discussions[line_code] end @@ -98,18 +98,18 @@ module DiffHelper [ content_tag(:span, link_to(truncate(blob.name, length: 40), tree)), '@', - content_tag(:span, commit_id, class: 'monospace'), + content_tag(:span, commit_id, class: 'commit-sha') ].join(' ').html_safe end - def commit_for_diff(diff_file) - return diff_file.content_commit if diff_file.content_commit + def diff_file_blob_raw_path(diff_file) + namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.content_sha, diff_file.file_path)) + end - if diff_file.deleted_file - @base_commit || @commit.parent || @commit - else - @commit - end + def diff_file_old_blob_raw_path(diff_file) + sha = diff_file.old_content_sha + return unless sha + namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.old_content_sha, diff_file.old_path)) end def diff_file_html_data(project, diff_file_path, diff_commit_id) @@ -120,8 +120,8 @@ module DiffHelper } end - def editable_diff?(diff) - !diff.deleted_file && @merge_request && @merge_request.source_project + def editable_diff?(diff_file) + !diff_file.deleted_file? && @merge_request && @merge_request.source_project end private diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 8ed99642c7a..ac8c518ac84 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -1,27 +1,27 @@ module DropdownsHelper def dropdown_tag(toggle_text, options: {}, &block) - content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do + content_tag :div, class: "dropdown #{options[:wrapper_class] if options.key?(:wrapper_class)}" do data_attr = { toggle: "dropdown" } - if options.has_key?(:data) + if options.key?(:data) data_attr = options[:data].merge(data_attr) end dropdown_output = dropdown_toggle(toggle_text, data_attr, options) - dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.has_key?(:dropdown_class)}") do + dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do output = "" - if options.has_key?(:title) + if options.key?(:title) output << dropdown_title(options[:title]) end - if options.has_key?(:filter) + if options.key?(:filter) output << dropdown_filter(options[:placeholder]) end - output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do - capture(&block) if block && !options.has_key?(:footer_content) + output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do + capture(&block) if block && !options.key?(:footer_content) end if block && options[:footer_content] @@ -41,7 +41,7 @@ module DropdownsHelper 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 + content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}") output << icon('chevron-down') output.html_safe diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index f927cfc998f..3b24f183785 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -12,7 +12,7 @@ module EmailsHelper "action" => { "@type" => "ViewAction", "name" => name, - "url" => url, + "url" => url } } diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 960111ca045..751d61955b7 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -41,7 +41,7 @@ module EventsHelper link_opts = { class: "event-filter-link", id: "#{key}_event_filter", - title: "Filter by #{tooltip.downcase}", + title: "Filter by #{tooltip.downcase}" } content_tag :li, class: active do @@ -164,9 +164,14 @@ module EventsHelper def event_note_title_html(event) if event.note_target - link_to(event_note_target_path(event), title: event.target_title, class: 'has-tooltip') do - "#{event.note_target_type} #{event.note_target_reference}" - end + text = raw("#{event.note_target_type} ") + + if event.commit_note? + content_tag(:span, event.note_target_reference, class: 'commit-sha') + else + event.note_target_reference + end + + link_to(text, event_note_target_path(event), title: event.target_title, class: 'has-tooltip') else content_tag(:strong, '(deleted)') end diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index 7bd212a3ef9..b981a1e8242 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -10,7 +10,7 @@ module ExploreHelper personal: params[:personal], archived: params[:archived], shared: params[:shared], - namespace_id: params[:namespace_id], + namespace_id: params[:namespace_id] } options = exist_opts.merge(options).delete_if { |key, value| value.blank? } diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index bcf71bc347b..8c7af62e199 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -50,8 +50,12 @@ module GitlabRoutingHelper namespace_project_cycle_analytics_path(project.namespace, project, *args) end - def project_builds_path(project, *args) - namespace_project_builds_path(project.namespace, project, *args) + def project_jobs_path(project, *args) + namespace_project_jobs_path(project.namespace, project, *args) + end + + def project_ref_path(project, ref_name, *args) + namespace_project_commits_path(project.namespace, project, ref_name, *args) end def project_container_registry_path(project, *args) @@ -106,8 +110,8 @@ module GitlabRoutingHelper namespace_project_pipeline_url(pipeline.project.namespace, pipeline.project, pipeline.id, *args) end - def pipeline_build_url(pipeline, build, *args) - namespace_project_build_url(pipeline.project.namespace, pipeline.project, build.id, *args) + def pipeline_job_url(pipeline, build, *args) + namespace_project_job_url(pipeline.project.namespace, pipeline.project, build.id, *args) end def commits_url(entity, *args) @@ -124,7 +128,7 @@ module GitlabRoutingHelper def preview_markdown_path(project, *args) if @snippet.is_a?(PersonalSnippet) - preview_markdown_snippet_path(@snippet) + preview_markdown_snippets_path else preview_markdown_namespace_project_path(project.namespace, project, *args) end @@ -211,13 +215,13 @@ module GitlabRoutingHelper case action when 'download' - download_namespace_project_build_artifacts_path(*args) + download_namespace_project_job_artifacts_path(*args) when 'browse' - browse_namespace_project_build_artifacts_path(*args) + browse_namespace_project_job_artifacts_path(*args) when 'file' - file_namespace_project_build_artifacts_path(*args) + file_namespace_project_job_artifacts_path(*args) when 'raw' - raw_namespace_project_build_artifacts_path(*args) + raw_namespace_project_job_artifacts_path(*args) end end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 55fa81e95ef..f29faeca22d 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -7,9 +7,10 @@ module IconsHelper # font-awesome-rails gem, but should we ever use a different icon pack in the # future we won't have to change hundreds of method calls. def icon(names, options = {}) - if (options.keys & %w[aria-hidden aria-label]).empty? - # Add `aria-hidden` if there are no aria's set + if (options.keys & %w[aria-hidden aria-label data-hidden]).empty? + # Add 'aria-hidden' and 'data-hidden' if they are not set in options. options['aria-hidden'] = true + options['data-hidden'] = true end options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) @@ -19,6 +20,8 @@ module IconsHelper case names when "standard" names = "key" + when "two-factor" + names = "key" end options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index f7d0ebcb16f..5e8f0849969 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -137,11 +137,9 @@ module IssuablesHelper author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "hidden-sm hidden-md hidden-lg") end - if issuable.tasks? - output << " ".html_safe - output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm") - output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg") - end + output << " ".html_safe + output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm") + output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg") output end @@ -201,6 +199,43 @@ module IssuablesHelper issuable_filter_params.any? { |k| params.key?(k) } end + def issuable_initial_data(issuable) + data = { + endpoint: namespace_project_issue_path(@project.namespace, @project, issuable), + canUpdate: can?(current_user, :update_issue, issuable), + canDestroy: can?(current_user, :destroy_issue, issuable), + canMove: current_user ? issuable.can_move?(current_user) : false, + issuableRef: issuable.to_reference, + isConfidential: issuable.confidential, + markdownPreviewUrl: preview_markdown_path(@project), + markdownDocs: help_page_path('user/markdown'), + projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id), + issuableTemplates: issuable_templates(issuable), + projectPath: ref_project.path, + projectNamespace: ref_project.namespace.full_path, + initialTitleHtml: markdown_field(issuable, :title), + initialTitleText: issuable.title, + initialDescriptionHtml: markdown_field(issuable, :description), + initialDescriptionText: issuable.description + } + + data.merge!(updated_at_by(issuable)) + + data.to_json + end + + def updated_at_by(issuable) + return {} unless issuable.is_edited? + + { + updatedAt: issuable.updated_at.to_time.iso8601, + updatedBy: { + name: issuable.last_edited_by.name, + path: user_path(issuable.last_edited_by) + } + } + end + private def sidebar_gutter_collapsed? diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index e5b1e6e8bc7..4e6e6805920 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -69,13 +69,12 @@ module LabelsHelper end def render_colored_label(label, label_suffix = '', tooltip: true) - label_color = label.color || Label::DEFAULT_COLOR - text_color = text_color_for_bg(label_color) + text_color = text_color_for_bg(label.color) # Intentionally not using content_tag here so that this method can be called # by LabelReferenceFilter span = %(<span class="label color-label #{"has-tooltip" if tooltip}" ) + - %(style="background-color: #{label_color}; color: #{text_color}" ) + + %(style="background-color: #{label.color}; color: #{text_color}" ) + %(title="#{escape_once(label.description)}" data-container="body">) + %(#{escape_once(label.name)}#{label_suffix}</span>) diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 4f55c12466a..941cfce8370 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -35,7 +35,7 @@ module MarkupHelper context = { project: @project, current_user: (current_user if defined?(current_user)), - pipeline: :single_line, + pipeline: :single_line } gfm_body = Banzai.render(body, context) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 23e55539f0a..39d30631646 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -54,7 +54,7 @@ module MergeRequestsHelper source_project_id: merge_request.source_project_id, target_project_id: merge_request.target_project_id, source_branch: merge_request.source_branch, - target_branch: merge_request.target_branch, + target_branch: merge_request.target_branch }, change_branches: true ) diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 52403640c05..c59d8dafc83 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -19,7 +19,7 @@ module NotesHelper id: noteable.id, class: noteable.class.name, resources: noteable.class.table_name, - project_id: noteable.project.id, + project_id: noteable.project.id }.to_json end @@ -34,7 +34,7 @@ module NotesHelper data = { line_code: line_code, - line_type: line_type, + line_type: line_type } if @use_legacy_diff_notes @@ -50,7 +50,7 @@ module NotesHelper def link_to_reply_discussion(discussion, line_type = nil) return unless current_user - data = { discussion_id: discussion.id, line_type: line_type } + data = { discussion_id: discussion.reply_id, line_type: line_type } button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', data: data, title: 'Add a reply' @@ -90,14 +90,18 @@ module NotesHelper end end - def note_url(note) + def note_url(note, project = @project) if note.noteable.is_a?(PersonalSnippet) snippet_note_path(note.noteable, note) else - namespace_project_note_path(@project.namespace, @project, note) + namespace_project_note_path(project.namespace, project, note) end end + def noteable_note_url(note) + Gitlab::UrlBuilder.build(note) + end + def form_resources if @snippet.is_a?(PersonalSnippet) [@note] diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 03cc8f2b6bd..fde961e2da4 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -21,30 +21,36 @@ module NotificationsHelper end def notification_title(level) + # Can be anything in `NotificationSetting.level: case level.to_sym when :participating - 'Participate' + s_('NotificationLevel|Participate') when :mention - 'On mention' + s_('NotificationLevel|On mention') else - level.to_s.titlecase + N_('NotificationLevel|Global') + N_('NotificationLevel|Watch') + N_('NotificationLevel|Disabled') + N_('NotificationLevel|Custom') + level = "NotificationLevel|#{level.to_s.humanize}" + s_(level) end end def notification_description(level) case level.to_sym when :participating - 'You will only receive notifications for threads you have participated in' + _('You will only receive notifications for threads you have participated in') when :mention - 'You will receive notifications only for comments in which you were @mentioned' + _('You will receive notifications only for comments in which you were @mentioned') when :watch - 'You will receive notifications for any activity' + _('You will receive notifications for any activity') when :disabled - 'You will not get any notifications via email' + _('You will not get any notifications via email') when :global - 'Use your global notification setting' + _('Use your global notification setting') when :custom - 'You will only receive notifications for the events you choose' + _('You will only receive notifications for the events you choose') end end @@ -76,11 +82,22 @@ module NotificationsHelper end def notification_event_name(event) + # All values from NotificationSetting::EMAIL_EVENTS case event when :success_pipeline - 'Successful pipeline' + s_('NotificationEvent|Successful pipeline') else - event.to_s.humanize + N_('NotificationEvent|New note') + N_('NotificationEvent|New issue') + N_('NotificationEvent|Reopen issue') + N_('NotificationEvent|Close issue') + N_('NotificationEvent|Reassign issue') + N_('NotificationEvent|New merge request') + N_('NotificationEvent|Close merge request') + N_('NotificationEvent|Reassign merge request') + N_('NotificationEvent|Merge merge request') + N_('NotificationEvent|Failed pipeline') + s_(event.to_s.humanize) end end end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index de959f13713..d36bb4ab074 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -49,7 +49,7 @@ module PreferencesHelper user_view = current_user.project_view - if @project.feature_available?(:repository, current_user) + if can?(current_user, :download_code, @project) user_view elsif user_view == "activity" "activity" diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb new file mode 100644 index 00000000000..45238f12ac7 --- /dev/null +++ b/app/helpers/profiles_helper.rb @@ -0,0 +1,7 @@ +module ProfilesHelper + def email_provider_label + return unless current_user.external_email? + + current_user.email_provider.present? ? Gitlab::OAuth::Provider.label_for(current_user.email_provider) : "LDAP" + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 8c26348a975..7441b58fddb 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -70,21 +70,30 @@ module ProjectsHelper end def remove_project_message(project) - "You are going to remove #{project.name_with_namespace}.\n Removed project CANNOT be restored!\n Are you ABSOLUTELY sure?" + _("You are going to remove %{project_name_with_namespace}.\nRemoved project CANNOT be restored!\nAre you ABSOLUTELY sure?") % + { project_name_with_namespace: project.name_with_namespace } end def transfer_project_message(project) - "You are going to transfer #{project.name_with_namespace} to another owner. Are you ABSOLUTELY sure?" + _("You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?") % + { project_name_with_namespace: project.name_with_namespace } end def remove_fork_project_message(project) - "You are going to remove the fork relationship to source project #{@project.forked_from_project.name_with_namespace}. Are you ABSOLUTELY sure?" + _("You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?") % + { forked_from_project: @project.forked_from_project.name_with_namespace } end def project_nav_tabs @nav_tabs ||= get_project_nav_tabs(@project, current_user) end + def project_search_tabs?(tab) + abilities = Array(search_tab_ability_map[tab]) + + abilities.any? { |ability| can?(current_user, ability, @project) } + end + def project_nav_tab?(name) project_nav_tabs.include? name end @@ -110,15 +119,13 @@ module ProjectsHelper end def license_short_name(project) - return 'LICENSE' if project.repository.license_key.nil? - - license = Licensee::License.new(project.repository.license_key) - - license.nickname || license.name + license = project.repository.license + license&.nickname || license&.name || 'LICENSE' end def last_push_event return unless current_user + return current_user.recent_push unless @project project_ids = [@project.id] if fork = current_user.fork_of(@project) @@ -134,11 +141,15 @@ module ProjectsHelper if @project.private? level = @project.project_feature.send(field) - options.delete('Everyone with access') - highest_available_option = options.values.max if level == ProjectFeature::ENABLED + disabled_option = ProjectFeature::ENABLED + highest_available_option = ProjectFeature::PRIVATE if level == disabled_option end - options = options_for_select(options, selected: highest_available_option || @project.project_feature.public_send(field)) + options = options_for_select( + options.invert, + selected: highest_available_option || @project.project_feature.public_send(field), + disabled: disabled_option + ) content_tag( :select, @@ -151,16 +162,25 @@ module ProjectsHelper end def link_to_autodeploy_doc - link_to 'About auto deploy', help_page_path('ci/autodeploy/index'), target: '_blank' + link_to _('About auto deploy'), help_page_path('ci/autodeploy/index'), target: '_blank' end def autodeploy_flash_notice(branch_name) - "Branch <strong>#{truncate(sanitize(branch_name))}</strong> was created. To set up auto deploy, \ - choose a GitLab CI Yaml template and commit your changes. #{link_to_autodeploy_doc}".html_safe + translation = _("Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}") % + { branch_name: truncate(sanitize(branch_name)), link_to_autodeploy_doc: link_to_autodeploy_doc } + translation.html_safe end def project_list_cache_key(project) - key = [project.namespace.cache_key, project.cache_key, controller.controller_name, controller.action_name, current_application_settings.cache_key, 'v2.4'] + key = [ + project.route.cache_key, + project.cache_key, + controller.controller_name, + controller.action_name, + current_application_settings.cache_key, + 'v2.4' + ] + key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status? key @@ -198,7 +218,17 @@ module ProjectsHelper nav_tabs << :container_registry end - tab_ability_map = { + tab_ability_map.each do |tab, ability| + if can?(current_user, ability, project) + nav_tabs << tab + end + end + + nav_tabs.flatten + end + + def tab_ability_map + { environments: :read_environment, milestones: :read_milestone, pipelines: :read_pipeline, @@ -210,24 +240,25 @@ module ProjectsHelper team: :read_project_member, wiki: :read_wiki } + end - tab_ability_map.each do |tab, ability| - if can?(current_user, ability, project) - nav_tabs << tab - end - end - - nav_tabs.flatten + def search_tab_ability_map + @search_tab_ability_map ||= tab_ability_map.merge( + blobs: :download_code, + commits: :download_code, + merge_requests: :read_merge_request, + notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet] + ) end def project_lfs_status(project) if project.lfs_enabled? content_tag(:span, class: 'lfs-enabled') do - 'Enabled' + s_('LFSStatus|Enabled') end else content_tag(:span, class: 'lfs-disabled') do - 'Disabled' + s_('LFSStatus|Disabled') end end end @@ -236,7 +267,7 @@ module ProjectsHelper if current_user current_user.name else - "Your name" + _("Your name") end end @@ -253,7 +284,7 @@ module ProjectsHelper when 'ssh' project.ssh_url_to_repo else - project.http_url_to_repo(current_user) + project.http_url_to_repo end end @@ -273,17 +304,18 @@ module ProjectsHelper if project.last_activity_at time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago') else - "Never" + s_("ProjectLastActivity|Never") end end def add_special_file_path(project, file_name:, commit_message: nil, branch_name: nil, context: nil) + commit_message ||= s_("CommitMessage|Add %{file_name}") % { file_name: file_name.downcase } namespace_project_new_blob_path( project.namespace, project, project.default_branch || 'master', file_name: file_name, - commit_message: commit_message || "Add #{file_name.downcase}", + commit_message: commit_message, branch_name: branch_name, context: context ) @@ -420,9 +452,9 @@ module ProjectsHelper def project_feature_options { - 'Disabled' => ProjectFeature::DISABLED, - 'Only team members' => ProjectFeature::PRIVATE, - 'Everyone with access' => ProjectFeature::ENABLED + ProjectFeature::DISABLED => s_('ProjectFeature|Disabled'), + ProjectFeature::PRIVATE => s_('ProjectFeature|Only team members'), + ProjectFeature::ENABLED => s_('ProjectFeature|Everyone with access') } end diff --git a/app/helpers/rss_helper.rb b/app/helpers/rss_helper.rb index ea5d2932ef4..9ac4df88dc3 100644 --- a/app/helpers/rss_helper.rb +++ b/app/helpers/rss_helper.rb @@ -1,5 +1,5 @@ module RssHelper def rss_url_options - { format: :atom, private_token: current_user.try(:private_token) } + { format: :atom, rss_token: current_user.try(:rss_token) } end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 8ff8db16514..9c46035057f 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -42,7 +42,7 @@ module SearchHelper { category: "Settings", label: "User settings", url: profile_path }, { category: "Settings", label: "SSH Keys", url: profile_keys_path }, { category: "Settings", label: "Dashboard", url: root_path }, - { category: "Settings", label: "Admin Section", url: admin_root_path }, + { category: "Settings", label: "Admin Section", url: admin_root_path } ] end @@ -57,7 +57,7 @@ module SearchHelper { category: "Help", label: "SSH Keys Help", url: help_page_path("ssh/README") }, { category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks/system_hooks") }, { category: "Help", label: "Webhooks Help", url: help_page_path("user/project/integrations/webhooks") }, - { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") }, + { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") } ] end @@ -76,7 +76,7 @@ module SearchHelper { category: "Current Project", label: "Milestones", url: namespace_project_milestones_path(@project.namespace, @project) }, { category: "Current Project", label: "Snippets", url: namespace_project_snippets_path(@project.namespace, @project) }, { category: "Current Project", label: "Members", url: namespace_project_settings_members_path(@project.namespace, @project) }, - { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }, + { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) } ] else [] diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 8706876ae4a..1a4f1431bdc 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -45,6 +45,14 @@ module SelectsHelper end end + with_feature_enabled_data_attribute = + case opts.delete(:with_feature_enabled) + when 'issues' then 'data-with-issues-enabled' + when 'merge_requests' then 'data-with-merge-requests-enabled' + end + + opts[with_feature_enabled_data_attribute] = true + hidden_field_tag(id, opts[:selected], opts) end @@ -67,7 +75,7 @@ module SelectsHelper current_user: opts[:current_user] || false, "push-code-to-protected-branches" => opts[:push_code_to_protected_branches], author_id: opts[:author_id] || '', - skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil, + skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil } end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 4882d9b71d2..b408ec0c6a4 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -58,7 +58,7 @@ module SortingHelper sort_value_due_date_soon => sort_title_due_date_soon, sort_value_due_date_later => sort_title_due_date_later, sort_value_start_date_soon => sort_title_start_date_soon, - sort_value_start_date_later => sort_title_start_date_later, + sort_value_start_date_later => sort_title_start_date_later } end diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index b739554a7a4..8bd3e5bf96a 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -7,8 +7,29 @@ module SubmoduleHelper def submodule_links(submodule_item, ref = nil, repository = @repository) url = repository.submodule_url_for(ref, submodule_item.path) +<<<<<<< HEAD if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/ namespace, project = $1, $2 +======= + if url == '.' || url == './' + url = File.join(Gitlab.config.gitlab.url, @project.full_path) + end + + if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/ + namespace, project = $1, $2 + gitlab_hosts = [Gitlab.config.gitlab.url, + Gitlab.config.gitlab_shell.ssh_path_prefix] + + gitlab_hosts.each do |host| + if url.start_with?(host) + namespace, _, project = url.sub(host, '').rpartition('/') + break + end + end + + namespace.sub!(/\A\//, '') + project.rstrip! +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 project.sub!(/\.git\z/, '') if self_url?(url, namespace, project) diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index d889d141101..209bd56b78a 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -17,7 +17,8 @@ module SystemNoteHelper 'visible' => 'icon_eye', 'milestone' => 'icon_clock_o', 'discussion' => 'icon_comment_o', - 'moved' => 'icon_arrow_circle_o_right' + 'moved' => 'icon_arrow_circle_o_right', + 'outdated' => 'icon_edit' }.freeze def icon_for_system_note(note) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index f19e2f9db9c..19286fadb19 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -24,10 +24,13 @@ module TodosHelper end def todo_target_link(todo) - target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target_reference}", todo_target_path(todo), - class: 'has-tooltip', - title: todo.target.title + text = raw("#{todo.target_type.titleize.downcase} ") + + if todo.for_commit? + content_tag(:span, todo.target_reference, class: 'commit-sha') + else + todo.target_reference + end + link_to text, todo_target_path(todo), class: 'has-tooltip', title: todo.target.title end def todo_target_path(todo) @@ -63,7 +66,7 @@ module TodosHelper project_id: params[:project_id], author_id: params[:author_id], type: params[:type], - action_id: params[:action_id], + action_id: params[:action_id] } end diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index b4aaf498068..35755bc149b 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -29,11 +29,11 @@ module VisibilityLevelHelper def project_visibility_level_description(level) case level when Gitlab::VisibilityLevel::PRIVATE - "Project access must be granted explicitly to each user." + _("Project access must be granted explicitly to each user.") when Gitlab::VisibilityLevel::INTERNAL - "The project can be cloned by any logged in user." + _("The project can be accessed by any logged in user.") when Gitlab::VisibilityLevel::PUBLIC - "The project can be cloned without any authentication." + _("The project can be accessed without any authentication.") end end @@ -81,7 +81,9 @@ module VisibilityLevelHelper end def visibility_level_label(level) - Project.visibility_levels.key(level) + # The visibility level can be: + # 'VisibilityLevel|Private', 'VisibilityLevel|Internal', 'VisibilityLevel|Public' + s_(Project.visibility_levels.key(level)) end def restricted_visibility_levels(show_all = false) diff --git a/app/mailers/base_mailer.rb b/app/mailers/base_mailer.rb index d2980db218a..654468bc7fe 100644 --- a/app/mailers/base_mailer.rb +++ b/app/mailers/base_mailer.rb @@ -1,4 +1,6 @@ class BaseMailer < ActionMailer::Base + around_action :render_with_default_locale + helper ApplicationHelper helper MarkupHelper @@ -14,6 +16,10 @@ class BaseMailer < ActionMailer::Base private + def render_with_default_locale(&block) + Gitlab::I18n.with_default_locale(&block) + end + def default_sender_address address = Mail::Address.new(Gitlab.config.gitlab.email_from) address.display_name = Gitlab.config.gitlab.email_display_name diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 0d7c2d20029..4cbd90c5817 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -15,8 +15,7 @@ class AbuseReport < ActiveRecord::Base alias_method :author, :reporter def remove_user(deleted_by:) - user.block - DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true) + user.delete_async(deleted_by: deleted_by, params: { hard_delete: true }) end def notify diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 043f57241a3..2192f76499d 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -13,13 +13,13 @@ class ApplicationSetting < ActiveRecord::Base [\r\n] # any number of newline characters }x - serialize :restricted_visibility_levels - serialize :import_sources - serialize :disabled_oauth_sign_in_sources, Array - serialize :domain_whitelist, Array - serialize :domain_blacklist, Array - serialize :repository_storages - serialize :sidekiq_throttling_queues, Array + serialize :restricted_visibility_levels # rubocop:disable Cop/ActiverecordSerialize + serialize :import_sources # rubocop:disable Cop/ActiverecordSerialize + serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiverecordSerialize + serialize :domain_whitelist, Array # rubocop:disable Cop/ActiverecordSerialize + serialize :domain_blacklist, Array # rubocop:disable Cop/ActiverecordSerialize + serialize :repository_storages # rubocop:disable Cop/ActiverecordSerialize + serialize :sidekiq_throttling_queues, Array # rubocop:disable Cop/ActiverecordSerialize cache_markdown_field :sign_in_text cache_markdown_field :help_page_text @@ -143,7 +143,7 @@ class ApplicationSetting < ActiveRecord::Base validates_each :restricted_visibility_levels do |record, attr, value| value&.each do |level| - unless Gitlab::VisibilityLevel.options.has_value?(level) + unless Gitlab::VisibilityLevel.options.value?(level) record.errors.add(attr, "'#{level}' is not a valid visibility level") end end @@ -151,7 +151,7 @@ class ApplicationSetting < ActiveRecord::Base validates_each :import_sources do |record, attr, value| value&.each do |source| - unless Gitlab::ImportSources.options.has_value?(source) + unless Gitlab::ImportSources.options.value?(source) record.errors.add(attr, "'#{source}' is not a import source") end end @@ -189,8 +189,9 @@ class ApplicationSetting < ActiveRecord::Base end def self.cached - ensure_cache_setup - Rails.cache.fetch(CACHE_KEY) + value = Rails.cache.read(CACHE_KEY) + ensure_cache_setup if value.present? + value end def self.ensure_cache_setup @@ -199,7 +200,7 @@ class ApplicationSetting < ActiveRecord::Base ApplicationSetting.define_attribute_methods end - def self.defaults_ce + def self.defaults { after_sign_up_text: nil, akismet_enabled: false, @@ -250,10 +251,6 @@ class ApplicationSetting < ActiveRecord::Base } end - def self.defaults - defaults_ce - end - def self.create_from_defaults create(defaults) end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 967ffd46db0..46d412fbd72 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -1,5 +1,5 @@ class AuditEvent < ActiveRecord::Base - serialize :details, Hash + serialize :details, Hash # rubocop:disable Cop/ActiverecordSerialize belongs_to :user, foreign_key: :author_id diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 6ada6fae4eb..ebe60441603 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -5,7 +5,7 @@ class AwardEmoji < ActiveRecord::Base include Participable include GhostUser - belongs_to :awardable, polymorphic: true + belongs_to :awardable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user validates :awardable, :user, presence: true diff --git a/app/models/blob.rb b/app/models/blob.rb index eaf0b713122..6a42a12891c 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -33,12 +33,31 @@ class Blob < SimpleDelegator BlobViewer::PDF, BlobViewer::BinarySTL, - BlobViewer::TextSTL, + BlobViewer::TextSTL + ].sort_by { |v| v.binary? ? 0 : 1 }.freeze + + AUXILIARY_VIEWERS = [ + BlobViewer::GitlabCiYml, + BlobViewer::RouteMap, + + BlobViewer::Readme, + BlobViewer::License, + BlobViewer::Contributing, + BlobViewer::Changelog, + + BlobViewer::Cartfile, + BlobViewer::ComposerJson, + BlobViewer::Gemfile, + BlobViewer::Gemspec, + BlobViewer::GodepsJson, + BlobViewer::PackageJson, + BlobViewer::Podfile, + BlobViewer::Podspec, + BlobViewer::PodspecJson, + BlobViewer::RequirementsTxt, + BlobViewer::YarnLock ].freeze - BINARY_VIEWERS = RICH_VIEWERS.select(&:binary?).freeze - TEXT_VIEWERS = RICH_VIEWERS.select(&:text?).freeze - attr_reader :project # Wrap a Gitlab::Git::Blob object, or return nil when given nil @@ -83,10 +102,6 @@ class Blob < SimpleDelegator raw_size == 0 end - def too_large? - size && truncated? - end - def external_storage_error? if external_storage == :lfs !project&.lfs_enabled? @@ -141,7 +156,7 @@ class Blob < SimpleDelegator end def readable_text? - text? && !stored_externally? && !too_large? + text? && !stored_externally? && !truncated? end def simple_viewer @@ -154,6 +169,12 @@ class Blob < SimpleDelegator @rich_viewer = rich_viewer_class&.new(self) end + def auxiliary_viewer + return @auxiliary_viewer if defined?(@auxiliary_viewer) + + @auxiliary_viewer = auxiliary_viewer_class&.new(self) + end + def rendered_as_text?(ignore_errors: true) simple_viewer.text? && (ignore_errors || simple_viewer.render_error.nil?) end @@ -162,9 +183,9 @@ class Blob < SimpleDelegator rendered_as_text? && rich_viewer end - def override_max_size! - simple_viewer&.override_max_size = true - rich_viewer&.override_max_size = true + def expand! + simple_viewer&.expanded = true + rich_viewer&.expanded = true end private @@ -180,17 +201,18 @@ class Blob < SimpleDelegator end def rich_viewer_class + viewer_class_from(RICH_VIEWERS) + end + + def auxiliary_viewer_class + viewer_class_from(AUXILIARY_VIEWERS) + end + + def viewer_class_from(classes) return if empty? || external_storage_error? - classes = - if stored_externally? - BINARY_VIEWERS + TEXT_VIEWERS - elsif binary? - BINARY_VIEWERS - else # text - TEXT_VIEWERS - end + verify_binary = !stored_externally? - classes.find { |viewer_class| viewer_class.can_render?(self) } + classes.find { |viewer_class| viewer_class.can_render?(self, verify_binary: verify_binary) } end end diff --git a/app/models/blob_viewer/auxiliary.rb b/app/models/blob_viewer/auxiliary.rb new file mode 100644 index 00000000000..1bea225f17c --- /dev/null +++ b/app/models/blob_viewer/auxiliary.rb @@ -0,0 +1,18 @@ +module BlobViewer + module Auxiliary + extend ActiveSupport::Concern + + include Gitlab::Allowable + + included do + self.loading_partial_name = 'loading_auxiliary' + self.type = :auxiliary + self.collapse_limit = 100.kilobytes + self.size_limit = 100.kilobytes + end + + def visible_to?(current_user) + true + end + end +end diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb index a8b91d8d6bc..e6119d25fab 100644 --- a/app/models/blob_viewer/base.rb +++ b/app/models/blob_viewer/base.rb @@ -1,18 +1,28 @@ module BlobViewer class Base - class_attribute :partial_name, :type, :extensions, :client_side, :binary, :switcher_icon, :switcher_title, :max_size, :absolute_max_size + PARTIAL_PATH_PREFIX = 'projects/blob/viewers'.freeze - delegate :partial_path, :rich?, :simple?, :client_side?, :server_side?, :text?, :binary?, to: :class + class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_types, :load_async, :binary, :switcher_icon, :switcher_title, :collapse_limit, :size_limit + + self.loading_partial_name = 'loading' + + delegate :partial_path, :loading_partial_path, :rich?, :simple?, :text?, :binary?, to: :class attr_reader :blob - attr_accessor :override_max_size + attr_accessor :expanded + + delegate :project, to: :blob def initialize(blob) @blob = blob end def self.partial_path - "projects/blob/viewers/#{partial_name}" + File.join(PARTIAL_PATH_PREFIX, partial_name) + end + + def self.loading_partial_path + File.join(PARTIAL_PATH_PREFIX, loading_partial_name) end def self.rich? @@ -23,12 +33,12 @@ module BlobViewer type == :simple end - def self.client_side? - client_side + def self.auxiliary? + type == :auxiliary end - def self.server_side? - !client_side? + def self.load_async? + load_async end def self.binary? @@ -39,20 +49,28 @@ module BlobViewer !binary? end - def self.can_render?(blob) - !extensions || extensions.include?(blob.extension) + def self.can_render?(blob, verify_binary: true) + return false if verify_binary && binary? != blob.binary? + return true if extensions&.include?(blob.extension) + return true if file_types&.include?(Gitlab::FileDetector.type_of(blob.path)) + + false end - def too_large? - blob.raw_size > max_size + def load_async? + self.class.load_async? && render_error.nil? end - def absolutely_too_large? - blob.raw_size > absolute_max_size + def collapsed? + return @collapsed if defined?(@collapsed) + + @collapsed = !expanded && collapse_limit && blob.raw_size > collapse_limit end - def can_override_max_size? - too_large? && !absolutely_too_large? + def too_large? + return @too_large if defined?(@too_large) + + @too_large = size_limit && blob.raw_size > size_limit end # This method is used on the server side to check whether we can attempt to @@ -67,31 +85,15 @@ module BlobViewer # binary from `blob_raw_url` and does its own format validation and error # rendering, especially for potentially large binary formats. def render_error - return @render_error if defined?(@render_error) - - @render_error = - if server_side_but_stored_externally? - # Files that are not stored in the repository, like LFS files and - # build artifacts, can only be rendered using a client-side viewer, - # since we do not want to read large amounts of data into memory on the - # server side. Client-side viewers use JS and can fetch the file from - # `blob_raw_url` using AJAX. - :server_side_but_stored_externally - elsif override_max_size ? absolutely_too_large? : too_large? - :too_large - end - end - - def prepare! - if server_side? && blob.project - blob.load_all_data!(blob.project.repository) + if too_large? + :too_large + elsif collapsed? + :collapsed end end - private - - def server_side_but_stored_externally? - server_side? && blob.stored_externally? + def prepare! + # To be overridden by subclasses end end end diff --git a/app/models/blob_viewer/cartfile.rb b/app/models/blob_viewer/cartfile.rb new file mode 100644 index 00000000000..d8471bc33c0 --- /dev/null +++ b/app/models/blob_viewer/cartfile.rb @@ -0,0 +1,15 @@ +module BlobViewer + class Cartfile < DependencyManager + include Static + + self.file_types = %i(cartfile) + + def manager_name + 'Carthage' + end + + def manager_url + 'https://github.com/Carthage/Carthage' + end + end +end diff --git a/app/models/blob_viewer/changelog.rb b/app/models/blob_viewer/changelog.rb new file mode 100644 index 00000000000..0464ae27f71 --- /dev/null +++ b/app/models/blob_viewer/changelog.rb @@ -0,0 +1,16 @@ +module BlobViewer + class Changelog < Base + include Auxiliary + include Static + + self.partial_name = 'changelog' + self.file_types = %i(changelog) + self.binary = false + + def render_error + return if project.repository.tag_count > 0 + + :no_tags + end + end +end diff --git a/app/models/blob_viewer/client_side.rb b/app/models/blob_viewer/client_side.rb index 42ec68f864b..079cfbe3616 100644 --- a/app/models/blob_viewer/client_side.rb +++ b/app/models/blob_viewer/client_side.rb @@ -3,9 +3,9 @@ module BlobViewer extend ActiveSupport::Concern included do - self.client_side = true - self.max_size = 10.megabytes - self.absolute_max_size = 50.megabytes + self.load_async = false + self.collapse_limit = 10.megabytes + self.size_limit = 50.megabytes end end end diff --git a/app/models/blob_viewer/composer_json.rb b/app/models/blob_viewer/composer_json.rb new file mode 100644 index 00000000000..ef8b4aef8e8 --- /dev/null +++ b/app/models/blob_viewer/composer_json.rb @@ -0,0 +1,23 @@ +module BlobViewer + class ComposerJson < DependencyManager + include ServerSide + + self.file_types = %i(composer_json) + + def manager_name + 'Composer' + end + + def manager_url + 'https://getcomposer.com/' + end + + def package_name + @package_name ||= package_name_from_json('name') + end + + def package_url + "https://packagist.org/packages/#{package_name}" + end + end +end diff --git a/app/models/blob_viewer/contributing.rb b/app/models/blob_viewer/contributing.rb new file mode 100644 index 00000000000..fbd1dd48697 --- /dev/null +++ b/app/models/blob_viewer/contributing.rb @@ -0,0 +1,10 @@ +module BlobViewer + class Contributing < Base + include Auxiliary + include Static + + self.partial_name = 'contributing' + self.file_types = %i(contributing) + self.binary = false + end +end diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb new file mode 100644 index 00000000000..a8d9be945dc --- /dev/null +++ b/app/models/blob_viewer/dependency_manager.rb @@ -0,0 +1,43 @@ +module BlobViewer + class DependencyManager < Base + include Auxiliary + + self.partial_name = 'dependency_manager' + self.binary = false + + def manager_name + raise NotImplementedError + end + + def manager_url + raise NotImplementedError + end + + def package_type + 'package' + end + + def package_name + nil + end + + def package_url + nil + end + + private + + def package_name_from_json(key) + prepare! + + JSON.parse(blob.data)[key] rescue nil + end + + def package_name_from_method_call(name) + prepare! + + match = blob.data.match(/#{name}\s*=\s*["'](?<name>[^"']+)["']/) + match[:name] if match + end + end +end diff --git a/app/models/blob_viewer/download.rb b/app/models/blob_viewer/download.rb index adc06587f69..074e7204814 100644 --- a/app/models/blob_viewer/download.rb +++ b/app/models/blob_viewer/download.rb @@ -1,17 +1,9 @@ module BlobViewer class Download < Base include Simple - # We treat the Download viewer as if it renders the content client-side, - # so that it doesn't attempt to load the entire blob contents and is - # rendered synchronously instead of loaded asynchronously. - include ClientSide + include Static self.partial_name = 'download' self.binary = true - - # We can always render the Download viewer, even if the blob is in LFS or too large. - def render_error - nil - end end end diff --git a/app/models/blob_viewer/gemfile.rb b/app/models/blob_viewer/gemfile.rb new file mode 100644 index 00000000000..fae8c8df23f --- /dev/null +++ b/app/models/blob_viewer/gemfile.rb @@ -0,0 +1,15 @@ +module BlobViewer + class Gemfile < DependencyManager + include Static + + self.file_types = %i(gemfile gemfile_lock) + + def manager_name + 'Bundler' + end + + def manager_url + 'http://bundler.io/' + end + end +end diff --git a/app/models/blob_viewer/gemspec.rb b/app/models/blob_viewer/gemspec.rb new file mode 100644 index 00000000000..7802edeb754 --- /dev/null +++ b/app/models/blob_viewer/gemspec.rb @@ -0,0 +1,27 @@ +module BlobViewer + class Gemspec < DependencyManager + include ServerSide + + self.file_types = %i(gemspec) + + def manager_name + 'RubyGems' + end + + def manager_url + 'https://rubygems.org/' + end + + def package_type + 'gem' + end + + def package_name + @package_name ||= package_name_from_method_call('name') + end + + def package_url + "https://rubygems.org/gems/#{package_name}" + end + end +end diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb new file mode 100644 index 00000000000..7267c3965d3 --- /dev/null +++ b/app/models/blob_viewer/gitlab_ci_yml.rb @@ -0,0 +1,23 @@ +module BlobViewer + class GitlabCiYml < Base + include ServerSide + include Auxiliary + + self.partial_name = 'gitlab_ci_yml' + self.loading_partial_name = 'gitlab_ci_yml_loading' + self.file_types = %i(gitlab_ci) + self.binary = false + + def validation_message + return @validation_message if defined?(@validation_message) + + prepare! + + @validation_message = Ci::GitlabCiYamlProcessor.validation_message(blob.data) + end + + def valid? + validation_message.blank? + end + end +end diff --git a/app/models/blob_viewer/godeps_json.rb b/app/models/blob_viewer/godeps_json.rb new file mode 100644 index 00000000000..e19a602603b --- /dev/null +++ b/app/models/blob_viewer/godeps_json.rb @@ -0,0 +1,15 @@ +module BlobViewer + class GodepsJson < DependencyManager + include Static + + self.file_types = %i(godeps_json) + + def manager_name + 'godep' + end + + def manager_url + 'https://github.com/tools/godep' + end + end +end diff --git a/app/models/blob_viewer/license.rb b/app/models/blob_viewer/license.rb new file mode 100644 index 00000000000..57355f2c3aa --- /dev/null +++ b/app/models/blob_viewer/license.rb @@ -0,0 +1,20 @@ +module BlobViewer + class License < Base + include Auxiliary + include Static + + self.partial_name = 'license' + self.file_types = %i(license) + self.binary = false + + def license + project.repository.license + end + + def render_error + return if license + + :unknown_license + end + end +end diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb index 8fdbab30dd1..33b59c4f512 100644 --- a/app/models/blob_viewer/markup.rb +++ b/app/models/blob_viewer/markup.rb @@ -5,6 +5,7 @@ module BlobViewer self.partial_name = 'markup' self.extensions = Gitlab::MarkupHelper::EXTENSIONS + self.file_types = %i(readme) self.binary = false end end diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb new file mode 100644 index 00000000000..09221efb56c --- /dev/null +++ b/app/models/blob_viewer/package_json.rb @@ -0,0 +1,23 @@ +module BlobViewer + class PackageJson < DependencyManager + include ServerSide + + self.file_types = %i(package_json) + + def manager_name + 'npm' + end + + def manager_url + 'https://www.npmjs.com/' + end + + def package_name + @package_name ||= package_name_from_json('name') + end + + def package_url + "https://www.npmjs.com/package/#{package_name}" + end + end +end diff --git a/app/models/blob_viewer/podfile.rb b/app/models/blob_viewer/podfile.rb new file mode 100644 index 00000000000..507bc734cb4 --- /dev/null +++ b/app/models/blob_viewer/podfile.rb @@ -0,0 +1,15 @@ +module BlobViewer + class Podfile < DependencyManager + include Static + + self.file_types = %i(podfile) + + def manager_name + 'CocoaPods' + end + + def manager_url + 'https://cocoapods.org/' + end + end +end diff --git a/app/models/blob_viewer/podspec.rb b/app/models/blob_viewer/podspec.rb new file mode 100644 index 00000000000..a4c242db3a9 --- /dev/null +++ b/app/models/blob_viewer/podspec.rb @@ -0,0 +1,27 @@ +module BlobViewer + class Podspec < DependencyManager + include ServerSide + + self.file_types = %i(podspec) + + def manager_name + 'CocoaPods' + end + + def manager_url + 'https://cocoapods.org/' + end + + def package_type + 'pod' + end + + def package_name + @package_name ||= package_name_from_method_call('name') + end + + def package_url + "https://cocoapods.org/pods/#{package_name}" + end + end +end diff --git a/app/models/blob_viewer/podspec_json.rb b/app/models/blob_viewer/podspec_json.rb new file mode 100644 index 00000000000..602f4a51fd9 --- /dev/null +++ b/app/models/blob_viewer/podspec_json.rb @@ -0,0 +1,9 @@ +module BlobViewer + class PodspecJson < Podspec + self.file_types = %i(podspec_json) + + def package_name + @package_name ||= package_name_from_json('name') + end + end +end diff --git a/app/models/blob_viewer/readme.rb b/app/models/blob_viewer/readme.rb new file mode 100644 index 00000000000..75c373a03bb --- /dev/null +++ b/app/models/blob_viewer/readme.rb @@ -0,0 +1,14 @@ +module BlobViewer + class Readme < Base + include Auxiliary + include Static + + self.partial_name = 'readme' + self.file_types = %i(readme) + self.binary = false + + def visible_to?(current_user) + can?(current_user, :read_wiki, project) + end + end +end diff --git a/app/models/blob_viewer/requirements_txt.rb b/app/models/blob_viewer/requirements_txt.rb new file mode 100644 index 00000000000..83ac55f61d0 --- /dev/null +++ b/app/models/blob_viewer/requirements_txt.rb @@ -0,0 +1,15 @@ +module BlobViewer + class RequirementsTxt < DependencyManager + include Static + + self.file_types = %i(requirements_txt) + + def manager_name + 'pip' + end + + def manager_url + 'https://pip.pypa.io/' + end + end +end diff --git a/app/models/blob_viewer/route_map.rb b/app/models/blob_viewer/route_map.rb new file mode 100644 index 00000000000..153b4eeb2c9 --- /dev/null +++ b/app/models/blob_viewer/route_map.rb @@ -0,0 +1,30 @@ +module BlobViewer + class RouteMap < Base + include ServerSide + include Auxiliary + + self.partial_name = 'route_map' + self.loading_partial_name = 'route_map_loading' + self.file_types = %i(route_map) + self.binary = false + + def validation_message + return @validation_message if defined?(@validation_message) + + prepare! + + @validation_message = + begin + Gitlab::RouteMap.new(blob.data) + + nil + rescue Gitlab::RouteMap::FormatError => e + e.message + end + end + + def valid? + validation_message.blank? + end + end +end diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb index 899107d02ea..05a3dd7d913 100644 --- a/app/models/blob_viewer/server_side.rb +++ b/app/models/blob_viewer/server_side.rb @@ -3,9 +3,28 @@ module BlobViewer extend ActiveSupport::Concern included do - self.client_side = false - self.max_size = 2.megabytes - self.absolute_max_size = 5.megabytes + self.load_async = true + self.collapse_limit = 2.megabytes + self.size_limit = 5.megabytes + end + + def prepare! + if blob.project + blob.load_all_data!(blob.project.repository) + end + end + + def render_error + if blob.stored_externally? + # Files that are not stored in the repository, like LFS files and + # build artifacts, can only be rendered using a client-side viewer, + # since we do not want to read large amounts of data into memory on the + # server side. Client-side viewers use JS and can fetch the file from + # `blob_raw_url` using AJAX. + return :server_side_but_stored_externally + end + + super end end end diff --git a/app/models/blob_viewer/static.rb b/app/models/blob_viewer/static.rb new file mode 100644 index 00000000000..c9e257e5388 --- /dev/null +++ b/app/models/blob_viewer/static.rb @@ -0,0 +1,14 @@ +module BlobViewer + module Static + extend ActiveSupport::Concern + + included do + self.load_async = false + end + + # We can always render a static viewer, even if the blob is too large. + def render_error + nil + end + end +end diff --git a/app/models/blob_viewer/text.rb b/app/models/blob_viewer/text.rb index e27b2c2b493..f68cbb7e212 100644 --- a/app/models/blob_viewer/text.rb +++ b/app/models/blob_viewer/text.rb @@ -5,7 +5,7 @@ module BlobViewer self.partial_name = 'text' self.binary = false - self.max_size = 1.megabyte - self.absolute_max_size = 10.megabytes + self.collapse_limit = 1.megabyte + self.size_limit = 10.megabytes end end diff --git a/app/models/blob_viewer/yarn_lock.rb b/app/models/blob_viewer/yarn_lock.rb new file mode 100644 index 00000000000..31588ddcbab --- /dev/null +++ b/app/models/blob_viewer/yarn_lock.rb @@ -0,0 +1,15 @@ +module BlobViewer + class YarnLock < DependencyManager + include Static + + self.file_types = %i(yarn_lock) + + def manager_name + 'Yarn' + end + + def manager_url + 'https://yarnpkg.com/' + end + end +end diff --git a/app/models/board.rb b/app/models/board.rb index cf8317891b5..18081a32157 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -5,6 +5,10 @@ class Board < ActiveRecord::Base validates :project, presence: true + def backlog_list + lists.merge(List.backlog).take + end + def closed_list lists.merge(List.closed).take end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 3c4a4d93349..cec1ca89a6a 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -19,8 +19,8 @@ module Ci ) end - serialize :options - serialize :yaml_variables, Gitlab::Serializer::Ci::Variables + serialize :options # rubocop:disable Cop/ActiverecordSerialize + serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiverecordSerialize delegate :name, to: :project, prefix: true @@ -47,10 +47,16 @@ module Ci before_destroy { unscoped_project } after_create :execute_hooks - after_save :update_project_statistics, if: :artifacts_size_changed? - after_destroy :update_project_statistics + after_commit :update_project_statistics_after_save, on: [:create, :update] + after_commit :update_project_statistics, on: :destroy class << self + # This is needed for url_for to work, + # as the controller is JobsController + def model_name + ActiveModel::Name.new(self, nil, 'job') + end + def first_pending pending.unstarted.order('created_at ASC').first end @@ -132,6 +138,17 @@ module Ci ExpandVariables.expand(environment, simple_variables) if environment end + def environment_url + return @environment_url if defined?(@environment_url) + + @environment_url = + if unexpanded_url = options&.dig(:environment, :url) + ExpandVariables.expand(unexpanded_url, simple_variables) + else + persisted_environment&.external_url + end + end + def has_environment? environment.present? end @@ -185,27 +202,30 @@ module Ci variables += project.deployment_variables if has_environment? variables += yaml_variables variables += user_variables - variables += project.secret_variables + variables += project.secret_variables_for(ref).map(&:to_runner_variable) variables += trigger_request.user_variables if trigger_request variables end # All variables, including those dependent on other variables def variables - variables = simple_variables - variables += persisted_environment.predefined_variables if persisted_environment.present? - variables + simple_variables.concat(persisted_environment_variables) end def merge_request - merge_requests = MergeRequest.includes(:merge_request_diff) - .where(source_branch: ref, - source_project: pipeline.project) - .reorder(iid: :asc) - - merge_requests.find do |merge_request| - merge_request.commits_sha.include?(pipeline.sha) - end + return @merge_request if defined?(@merge_request) + + @merge_request ||= + begin + merge_requests = MergeRequest.includes(:merge_request_diff) + .where(source_branch: ref, + source_project: pipeline.project) + .reorder(iid: :desc) + + merge_requests.find do |merge_request| + merge_request.commits_sha.include?(pipeline.sha) + end + end end def repo_url @@ -249,38 +269,6 @@ module Ci Time.now - updated_at > 15.minutes.to_i end - ## - # Deprecated - # - # This contains a hotfix for CI build data integrity, see #4246 - # - # This method is used by `ArtifactUploader` to create a store_dir. - # Warning: Uploader uses it after AND before file has been stored. - # - # This method returns old path to artifacts only if it already exists. - # - def artifacts_path - # We need the project even if it's soft deleted, because whenever - # we're really deleting the project, we'll also delete the builds, - # and in order to delete the builds, we need to know where to find - # the artifacts, which is depending on the data of the project. - # We need to retain the project in this case. - the_project = project || unscoped_project - - old = File.join(created_at.utc.strftime('%Y_%m'), - the_project.ci_id.to_s, - id.to_s) - - old_store = File.join(ArtifactUploader.artifacts_path, old) - return old if the_project.ci_id && File.directory?(old_store) - - File.join( - created_at.utc.strftime('%Y_%m'), - the_project.id.to_s, - id.to_s - ) - end - def valid_token?(token) self.token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token) end @@ -300,8 +288,8 @@ module Ci def execute_hooks return unless project build_data = Gitlab::DataBuilder::Build.build(self) - project.execute_hooks(build_data.dup, :build_hooks) - project.execute_services(build_data.dup, :build_hooks) + project.execute_hooks(build_data.dup, :job_hooks) + project.execute_services(build_data.dup, :job_hooks) PagesService.new(build_data).execute project.running_or_pending_build_count(force: true) end @@ -361,7 +349,7 @@ module Ci end def has_expiring_artifacts? - artifacts_expire_at.present? + artifacts_expire_at.present? && artifacts_expire_at > Time.now end def keep_artifacts! @@ -488,6 +476,18 @@ module Ci variables.concat(legacy_variables) end + def persisted_environment_variables + return [] unless persisted_environment + + variables = persisted_environment.predefined_variables + + if url = environment_url + variables << { key: 'CI_ENVIRONMENT_URL', value: url, public: true } + end + + variables + end + def legacy_variables variables = [ { key: 'CI_BUILD_ID', value: id.to_s, public: true }, @@ -517,5 +517,11 @@ module Ci ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size]) end + + def update_project_statistics_after_save + if previous_changes.include?('artifacts_size') + update_project_statistics + end + end end end diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb new file mode 100644 index 00000000000..9b536af672b --- /dev/null +++ b/app/models/ci/legacy_stage.rb @@ -0,0 +1,64 @@ +module Ci + # Currently this is artificial object, constructed dynamically + # We should migrate this object to actual database record in the future + class LegacyStage + include StaticModel + + attr_reader :pipeline, :name + + delegate :project, to: :pipeline + + def initialize(pipeline, name:, status: nil, warnings: nil) + @pipeline = pipeline + @name = name + @status = status + @warnings = warnings + end + + def groups + @groups ||= statuses.ordered.latest + .sort_by(&:sortable_name).group_by(&:group_name) + .map do |group_name, grouped_statuses| + Ci::Group.new(self, name: group_name, jobs: grouped_statuses) + end + end + + def to_param + name + end + + def statuses_count + @statuses_count ||= statuses.count + end + + def status + @status ||= statuses.latest.status + end + + def detailed_status(current_user) + Gitlab::Ci::Status::Stage::Factory + .new(self, current_user) + .fabricate! + end + + def statuses + @statuses ||= pipeline.statuses.where(stage: name) + end + + def builds + @builds ||= pipeline.builds.where(stage: name) + end + + def success? + status.to_s == 'success' + end + + def has_warnings? + if @warnings.is_a?(Integer) + @warnings > 0 + else + statuses.latest.failed_but_allowed.any? + end + end + end +end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index db994b861e5..9ddecba5183 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -11,21 +11,27 @@ module Ci belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule' - has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' - has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id' - + has_many :stages has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id has_many :builds, foreign_key: :commit_id has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id + # Merge requests for which the current pipeline is running against + # the merge request's latest commit. + has_many :merge_requests, foreign_key: "head_pipeline_id" + has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :retryable_builds, -> { latest.failed_or_canceled }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus' - has_many :manual_actions, -> { latest.manual_actions }, foreign_key: :commit_id, class_name: 'Ci::Build' - has_many :artifacts, -> { latest.with_artifacts_not_expired }, foreign_key: :commit_id, class_name: 'Ci::Build' + has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build' + has_many :artifacts, -> { latest.with_artifacts_not_expired.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build' + + has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' + has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id' delegate :id, to: :project, prefix: true + validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } validates :status, presence: { unless: :importing? } @@ -33,6 +39,16 @@ module Ci after_create :keep_around_commits, unless: :importing? + enum source: { + unknown: nil, + push: 1, + web: 2, + trigger: 3, + schedule: 4, + api: 5, + external: 6 + } + state_machine :status, initial: :created do event :enqueue do transition created: :pending @@ -147,21 +163,21 @@ module Ci where.not(duration: nil).sum(:duration) end - def stage(name) - stage = Ci::Stage.new(self, name: name) - stage unless stage.statuses_count.zero? - end - def stages_count statuses.select(:stage).distinct.count end - def stages_name + def stages_names statuses.order(:stage_idx).distinct. pluck(:stage, :stage_idx).map(&:first) end - def stages + def legacy_stage(name) + stage = Ci::LegacyStage.new(self, name: name) + stage unless stage.statuses_count.zero? + end + + def legacy_stages # TODO, this needs refactoring, see gitlab-ce#26481. stages_query = statuses @@ -176,7 +192,7 @@ module Ci .pluck('sg.stage', status_sql, "(#{warnings_sql})") stages_with_statuses.map do |stage| - Ci::Stage.new(self, Hash[%i[name status warnings].zip(stage)]) + Ci::LegacyStage.new(self, Hash[%i[name status warnings].zip(stage)]) end end @@ -265,10 +281,6 @@ module Ci commit.sha == sha end - def triggered? - trigger_requests.any? - end - def retried @retried ||= (statuses.order(id: :desc) - statuses.latest) end @@ -280,12 +292,14 @@ module Ci end end - def config_builds_attributes + def stage_seeds return [] unless config_processor - config_processor. - builds_for_ref(ref, tag?, trigger_requests.first). - sort_by { |build| build[:stage_idx] } + @stage_seeds ||= config_processor.stage_seeds(self) + end + + def has_stage_seeds? + stage_seeds.any? end def has_warnings? @@ -293,7 +307,7 @@ module Ci end def config_processor - return nil unless ci_yaml_file + return unless ci_yaml_file return @config_processor if defined?(@config_processor) @config_processor ||= begin @@ -381,14 +395,6 @@ module Ci 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.head_pipeline.try(:id) == self.id } - end - # All the merge requests for which the current pipeline runs/ran against def all_merge_requests @all_merge_requests ||= project.merge_requests.where(source_branch: ref) diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 6d7cc83971e..45d8cd34359 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -10,9 +10,9 @@ module Ci has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline' has_many :pipelines - validates :cron, unless: :importing_or_inactive?, cron: true, presence: { unless: :importing_or_inactive? } - validates :cron_timezone, cron_timezone: true, presence: { unless: :importing_or_inactive? } - validates :ref, presence: { unless: :importing_or_inactive? } + validates :cron, unless: :importing?, cron: true, presence: { unless: :importing? } + validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? } + validates :ref, presence: { unless: :importing? } validates :description, presence: true before_save :set_next_run_at @@ -24,12 +24,20 @@ module Ci owner == current_user end + def own!(user) + update(owner: user) + end + def inactive? !active? end - def importing_or_inactive? - importing? || inactive? + def deactivate! + update_attribute(:active, false) + end + + def runnable_by_owner? + Ability.allowed?(owner, :create_pipeline, project) end def set_next_run_at diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 9bda3186c30..59570924c8d 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -1,64 +1,11 @@ module Ci - # Currently this is artificial object, constructed dynamically - # We should migrate this object to actual database record in the future - class Stage - include StaticModel + class Stage < ActiveRecord::Base + extend Ci::Model - attr_reader :pipeline, :name + belongs_to :project + belongs_to :pipeline - delegate :project, to: :pipeline - - def initialize(pipeline, name:, status: nil, warnings: nil) - @pipeline = pipeline - @name = name - @status = status - @warnings = warnings - end - - def groups - @groups ||= statuses.ordered.latest - .sort_by(&:sortable_name).group_by(&:group_name) - .map do |group_name, grouped_statuses| - Ci::Group.new(self, name: group_name, jobs: grouped_statuses) - end - end - - def to_param - name - end - - def statuses_count - @statuses_count ||= statuses.count - end - - def status - @status ||= statuses.latest.status - end - - def detailed_status(current_user) - Gitlab::Ci::Status::Stage::Factory - .new(self, current_user) - .fabricate! - end - - def statuses - @statuses ||= pipeline.statuses.where(stage: name) - end - - def builds - @builds ||= pipeline.builds.where(stage: name) - end - - def success? - status.to_s == 'success' - end - - def has_warnings? - if @warnings.is_a?(Integer) - @warnings > 0 - else - statuses.latest.failed_but_allowed.any? - end - end + has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id + has_many :builds, foreign_key: :commit_id end end diff --git a/app/models/ci/trigger_request.rb b/app/models/ci/trigger_request.rb index 2b807731d0d..564334ad1ad 100644 --- a/app/models/ci/trigger_request.rb +++ b/app/models/ci/trigger_request.rb @@ -6,7 +6,7 @@ module Ci belongs_to :pipeline, foreign_key: :commit_id has_many :builds - serialize :variables + serialize :variables # rubocop:disable Cop/ActiverecordSerialize def user_variables return [] unless variables diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 6c6586110c5..f235260208f 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -12,11 +12,16 @@ module Ci message: "can contain only letters, digits and '_'." } scope :order_key_asc, -> { reorder(key: :asc) } + scope :unprotected, -> { where(protected: false) } attr_encrypted :value, mode: :per_attribute_iv_and_salt, insecure_mode: true, key: Gitlab::Application.secrets.db_key_base, algorithm: 'aes-256-cbc' + + def to_runner_variable + { key: key, value: value, public: false } + end end end diff --git a/app/models/commit.rb b/app/models/commit.rb index 9359b323ed4..1a766c9f6d0 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -14,7 +14,7 @@ class Commit participant :committer participant :notes_with_associations - attr_accessor :project + attr_accessor :project, :author DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines] @@ -49,7 +49,7 @@ class Commit def max_diff_options { max_files: DIFF_HARD_LIMIT_FILES, - max_lines: DIFF_HARD_LIMIT_LINES, + max_lines: DIFF_HARD_LIMIT_LINES } end @@ -177,7 +177,7 @@ class Commit if RequestStore.active? key = "commit_author:#{author_email.downcase}" # nil is a valid value since no author may exist in the system - if RequestStore.store.has_key?(key) + if RequestStore.store.key?(key) @author = RequestStore.store[key] else @author = find_author_by_any_email @@ -326,16 +326,23 @@ class Commit end def raw_diffs(*args) - use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) - deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only] - - if use_gitaly && !deltas_only - Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) + if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) + Gitlab::GitalyClient::Commit.new(project.repository).diff_from_parent(self, *args) else raw.diffs(*args) end end + def raw_deltas + @deltas ||= Gitlab::GitalyClient.migrate(:commit_deltas) do |is_enabled| + if is_enabled + Gitlab::GitalyClient::Commit.new(project.repository).commit_deltas(self) + else + raw.deltas + end + end + end + def diffs(diff_options = nil) Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options) end @@ -373,7 +380,7 @@ class Commit def repo_changes changes = { added: [], modified: [], removed: [] } - raw_diffs(deltas_only: true).each do |diff| + raw_deltas.each do |diff| if diff.deleted_file changes[:removed] << diff.old_path elsif diff.renamed_file || diff.new_file diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index fe63728ea23..633a3141bb2 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -5,10 +5,10 @@ class CommitStatus < ActiveRecord::Base self.table_name = 'ci_builds' + belongs_to :user belongs_to :project belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' - belongs_to :user delegate :commit, to: :pipeline delegate :sha, :short_sha, to: :pipeline @@ -18,7 +18,11 @@ class CommitStatus < ActiveRecord::Base validates :name, presence: true alias_attribute :author, :user +<<<<<<< HEAD +======= + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 scope :failed_but_allowed, -> do where(allow_failure: true, status: [:failed, :canceled]) end @@ -83,7 +87,7 @@ class CommitStatus < ActiveRecord::Base next if transition.loopback? commit_status.run_after_commit do - pipeline.try do |pipeline| + if pipeline if complete? || manual? PipelineProcessWorker.perform_async(pipeline.id) else @@ -91,6 +95,8 @@ class CommitStatus < ActiveRecord::Base end ExpireJobCacheWorker.perform_async(commit_status.id) end + + ExpireJobCacheWorker.perform_async(commit_status.id) end end @@ -126,6 +132,11 @@ class CommitStatus < ActiveRecord::Base false end + # To be overriden when inherrited from + def retryable? + false + end + def stuck? false end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb new file mode 100644 index 00000000000..8fbfed11bdf --- /dev/null +++ b/app/models/concerns/avatarable.rb @@ -0,0 +1,18 @@ +module Avatarable + extend ActiveSupport::Concern + + def avatar_path(only_path: true) + return unless self[:avatar].present? + + # If only_path is true then use the relative path of avatar. + # Otherwise use full path (including host). + asset_host = ActionController::Base.asset_host + gitlab_host = only_path ? gitlab_config.relative_url_root : gitlab_config.url + + # If asset_host is set then it is expected that assets are handled by a standalone host. + # That means we do not want to get GitLab's relative_url_root option anymore. + host = asset_host.present? ? asset_host : gitlab_host + + [host, avatar.url].join + end +end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index a7bdf5587b2..eee1a36ac6b 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -47,4 +47,12 @@ module DiscussionOnDiff prev_lines end + + def line_code_in_diffs(diff_refs) + if active?(diff_refs) + line_code + elsif diff_refs && created_at_diff?(diff_refs) + original_line_code + end + end end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index dff7b6e3523..3c9c6584e02 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -82,7 +82,7 @@ module HasStatus scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) } scope :cancelable, -> do - where(status: [:running, :pending, :created, :manual]) + where(status: [:running, :pending, :created]) end end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 85505d235b7..c034bf9cbc0 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -79,6 +79,8 @@ module Mentionable # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. def referenced_mentionables(current_user = self.author) + return [] unless matches_cross_reference_regex? + refs = all_references(current_user) refs = (refs.issues + refs.merge_requests + refs.commits) @@ -88,6 +90,20 @@ module Mentionable refs.reject { |ref| ref == local_reference } end + # Uses regex to quickly determine if mentionables might be referenced + # Allows heavy processing to be skipped + def matches_cross_reference_regex? + reference_pattern = if !project || project.default_issues_tracker? + ReferenceRegexes::DEFAULT_PATTERN + else + ReferenceRegexes::EXTERNAL_PATTERN + end + + self.class.mentionable_attrs.any? do |attr, _| + __send__(attr) =~ reference_pattern + end + 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 = []) refs = referenced_mentionables(author) diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb new file mode 100644 index 00000000000..1848230ec7e --- /dev/null +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -0,0 +1,22 @@ +module Mentionable + module ReferenceRegexes + def self.reference_pattern(link_patterns, issue_pattern) + Regexp.union(link_patterns, + issue_pattern, + Commit.reference_pattern, + MergeRequest.reference_pattern) + end + + DEFAULT_PATTERN = begin + issue_pattern = Issue.reference_pattern + link_patterns = Regexp.union([Issue, Commit, MergeRequest].map(&:link_reference_pattern)) + reference_pattern(link_patterns, issue_pattern) + end + + EXTERNAL_PATTERN = begin + issue_pattern = ExternalIssue.reference_pattern + link_patterns = URI.regexp(%w(http https)) + reference_pattern(link_patterns, issue_pattern) + end + end +end diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index 6359f7596b1..f734952fa6c 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -33,14 +33,4 @@ module NoteOnDiff def created_at_diff?(diff_refs) false end - - private - - def noteable_diff_refs - if noteable.respond_to?(:diff_sha_refs) - noteable.diff_sha_refs - else - noteable.diff_refs - end - end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index dd1e6630642..c7bdc997eca 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -43,7 +43,12 @@ module Noteable end def resolvable_discussions - @resolvable_discussions ||= discussion_notes.resolvable.discussions(self) + @resolvable_discussions ||= + if defined?(@discussions) + @discussions.select(&:resolvable?) + else + discussion_notes.resolvable.discussions(self) + end end def discussions_resolvable? diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb index c41b807df8a..a40148a4394 100644 --- a/app/models/concerns/protected_branch_access.rb +++ b/app/models/concerns/protected_branch_access.rb @@ -7,5 +7,27 @@ module ProtectedBranchAccess belongs_to :protected_branch delegate :project, to: :protected_branch + + validates :access_level, presence: true, inclusion: { + in: [ + Gitlab::Access::MASTER, + Gitlab::Access::DEVELOPER, + Gitlab::Access::NO_ACCESS + ] + } + + def self.human_access_levels + { + Gitlab::Access::MASTER => "Masters", + Gitlab::Access::DEVELOPER => "Developers + Masters", + Gitlab::Access::NO_ACCESS => "No one" + }.with_indifferent_access + end + + def check_access(user) + return false if access_level == Gitlab::Access::NO_ACCESS + + super + end end end diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index 62eaec2407f..47e71c58557 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -8,32 +8,44 @@ module ProtectedRef validates :project, presence: true delegate :matching, :matches?, :wildcard?, to: :ref_matcher + end + + def commit + project.commit(self.name) + end + + class_methods do + def protected_ref_access_levels(*types) + types.each do |type| + has_many :"#{type}_access_levels", dependent: :destroy + + validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." } - def self.protected_ref_accessible_to?(ref, user, action:) + accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true + end + end + + def protected_ref_accessible_to?(ref, user, action:) access_levels_for_ref(ref, action: action).any? do |access_level| access_level.check_access(user) end end - def self.developers_can?(action, ref) + def developers_can?(action, ref) access_levels_for_ref(ref, action: action).any? do |access_level| access_level.access_level == Gitlab::Access::DEVELOPER end end - def self.access_levels_for_ref(ref, action:) + def access_levels_for_ref(ref, action:) self.matching(ref).map(&:"#{action}_access_levels").flatten end - def self.matching(ref_name, protected_refs: nil) + def matching(ref_name, protected_refs: nil) ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs) end end - def commit - project.commit(self.name) - end - private def ref_matcher diff --git a/app/models/conversational_development_index/card.rb b/app/models/conversational_development_index/card.rb new file mode 100644 index 00000000000..e8f09dc9161 --- /dev/null +++ b/app/models/conversational_development_index/card.rb @@ -0,0 +1,26 @@ +module ConversationalDevelopmentIndex + class Card + attr_accessor :metric, :title, :description, :feature, :blog, :docs + + def initialize(metric:, title:, description:, feature:, blog:, docs: nil) + self.metric = metric + self.title = title + self.description = description + self.feature = feature + self.blog = blog + self.docs = docs + end + + def instance_score + metric.instance_score(feature) + end + + def leader_score + metric.leader_score(feature) + end + + def percentage_score + metric.percentage_score(feature) + end + end +end diff --git a/app/models/conversational_development_index/idea_to_production_step.rb b/app/models/conversational_development_index/idea_to_production_step.rb new file mode 100644 index 00000000000..6e1753c9f30 --- /dev/null +++ b/app/models/conversational_development_index/idea_to_production_step.rb @@ -0,0 +1,19 @@ +module ConversationalDevelopmentIndex + class IdeaToProductionStep + attr_accessor :metric, :title, :features + + def initialize(metric:, title:, features:) + self.metric = metric + self.title = title + self.features = features + end + + def percentage_score + sum = features.sum do |feature| + metric.percentage_score(feature) + end + + sum / features.size.to_f + end + end +end diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb new file mode 100644 index 00000000000..f42f516f99a --- /dev/null +++ b/app/models/conversational_development_index/metric.rb @@ -0,0 +1,21 @@ +module ConversationalDevelopmentIndex + class Metric < ActiveRecord::Base + include Presentable + + self.table_name = 'conversational_development_index_metrics' + + def instance_score(feature) + self["instance_#{feature}"] + end + + def leader_score(feature) + self["leader_#{feature}"] + end + + def percentage_score(feature) + return 100 if leader_score(feature).zero? + + 100 * instance_score(feature) / leader_score(feature) + end + end +end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index f83d9e8edee..d45ba97353f 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -4,7 +4,7 @@ class Deployment < ActiveRecord::Base belongs_to :project, required: true, validate: true belongs_to :environment, required: true, validate: true belongs_to :user - belongs_to :deployable, polymorphic: true + belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations validates :sha, presence: true validates :ref, presence: true @@ -12,6 +12,7 @@ class Deployment < ActiveRecord::Base delegate :name, to: :environment, prefix: true after_create :create_ref + after_create :invalidate_cache def commit project.commit(sha) @@ -33,6 +34,10 @@ class Deployment < ActiveRecord::Base project.repository.create_ref(ref, ref_path) end + def invalidate_cache + environment.expire_etag_cache + end + def manual_actions @manual_actions ||= deployable.try(:other_actions) end @@ -103,6 +108,7 @@ class Deployment < ActiveRecord::Base project.monitoring_service.present? end +<<<<<<< HEAD def metrics(timeframe) return {} unless has_metrics? @@ -112,6 +118,12 @@ class Deployment < ActiveRecord::Base metrics = project.monitoring_service.metrics(environment, timeframe_start: timeframe_start, timeframe_end: timeframe_end) metrics&.merge(deployment_time: created_at.to_i) || {} +======= + def metrics + return {} unless has_metrics? + + project.monitoring_service.deployment_metrics(self) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end private diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index d627fbe327f..07c4846e2ac 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -10,6 +10,7 @@ class DiffDiscussion < Discussion delegate :position, :original_position, + :change_position, to: :first_note @@ -19,27 +20,15 @@ class DiffDiscussion < Discussion def merge_request_version_params return unless for_merge_request? + return {} if active? - if active? - {} - else - diff_refs = position.diff_refs - - if diff = noteable.merge_request_diff_for(diff_refs) - { diff_id: diff.id } - elsif diff = noteable.merge_request_diff_for(diff_refs.head_sha) - { - diff_id: diff.id, - start_sha: diff_refs.start_sha - } - end - end + noteable.version_params_for(position.diff_refs) end def reply_attributes super.merge( original_position: original_position.to_json, - position: position.to_json, + position: position.to_json ) end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 76c59199afd..20ef1378500 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -6,8 +6,9 @@ class DiffNote < Note NOTEABLE_TYPES = %w(MergeRequest Commit).freeze - serialize :original_position, Gitlab::Diff::Position - serialize :position, Gitlab::Diff::Position + serialize :original_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize + serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize + serialize :change_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize validates :original_position, presence: true validates :position, presence: true @@ -25,7 +26,7 @@ class DiffNote < Note DiffDiscussion end - %i(original_position position).each do |meth| + %i(original_position position change_position).each do |meth| define_method "#{meth}=" do |new_position| if new_position.is_a?(String) new_position = JSON.parse(new_position) rescue nil @@ -36,6 +37,8 @@ class DiffNote < Note new_position = Gitlab::Diff::Position.new(new_position) end + return if new_position == read_attribute(meth) + super(new_position) end end @@ -45,7 +48,7 @@ class DiffNote < Note end def diff_line - @diff_line ||= diff_file.line_for_position(self.original_position) if diff_file + @diff_line ||= diff_file&.line_for_position(self.original_position) end def for_line?(line) @@ -60,7 +63,7 @@ class DiffNote < Note return false unless supported? return true if for_commit? - diff_refs ||= noteable_diff_refs + diff_refs ||= noteable.diff_refs self.position.diff_refs == diff_refs end @@ -92,13 +95,21 @@ class DiffNote < Note return if active? - Notes::DiffPositionUpdateService.new( - self.project, - nil, + tracer = Gitlab::Diff::PositionTracer.new( + project: self.project, old_diff_refs: self.position.diff_refs, - new_diff_refs: noteable_diff_refs, + new_diff_refs: self.noteable.diff_refs, paths: self.position.paths - ).execute(self) + ) + + result = tracer.trace(self.position) + return unless result + + if result[:outdated] + self.change_position = result[:position] + else + self.position = result[:position] + end end def verify_supported diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 0b6b920ed66..d1cec7613af 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -21,7 +21,8 @@ class Discussion end def self.build_collection(notes, context_noteable = nil) - notes.group_by { |n| n.discussion_id(context_noteable) }.values.map { |notes| build(notes, context_noteable) } + grouped_notes = notes.group_by { |n| n.discussion_id(context_noteable) } + grouped_notes.values.map { |notes| build(notes, context_noteable) } end # Returns an alphanumeric discussion ID based on `build_discussion_id` @@ -84,6 +85,12 @@ class Discussion first_note.discussion_id(context_noteable) end + def reply_id + # To reply to this discussion, we need the actual discussion_id from the database, + # not the potentially overwritten one based on the noteable. + first_note.discussion_id + end + alias_method :to_param, :id def diff_discussion? diff --git a/app/models/environment.rb b/app/models/environment.rb index bf33010fd21..6211a5c1e63 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -57,12 +57,16 @@ class Environment < ActiveRecord::Base state :available state :stopped + + after_transition do |environment| + environment.expire_etag_cache + end end def predefined_variables [ { key: 'CI_ENVIRONMENT_NAME', value: name, public: true }, - { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true }, + { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true } ] end @@ -150,7 +154,7 @@ class Environment < ActiveRecord::Base end def metrics - project.monitoring_service.metrics(self) if has_metrics? + project.monitoring_service.environment_metrics(self) if has_metrics? end # An environment name is not necessarily suitable for use in URLs, DNS @@ -196,6 +200,18 @@ class Environment < ActiveRecord::Base [external_url, public_path].join('/') end + def expire_etag_cache + Gitlab::EtagCaching::Store.new.tap do |store| + store.touch(etag_cache_key) + end + end + + def etag_cache_key + Gitlab::Routing.url_helpers.namespace_project_environments_path( + project.namespace, + project) + end + private # Slugifying a name may remove the uniqueness guarantee afforded by it being diff --git a/app/models/event.rb b/app/models/event.rb index e6fad46077a..fad6ff03927 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -14,6 +14,30 @@ class Event < ActiveRecord::Base DESTROYED = 10 EXPIRED = 11 # User left project due to expiry + ACTIONS = HashWithIndifferentAccess.new( + created: CREATED, + updated: UPDATED, + closed: CLOSED, + reopened: REOPENED, + pushed: PUSHED, + commented: COMMENTED, + merged: MERGED, + joined: JOINED, + left: LEFT, + destroyed: DESTROYED, + expired: EXPIRED + ).freeze + + TARGET_TYPES = HashWithIndifferentAccess.new( + issue: Issue, + milestone: Milestone, + merge_request: MergeRequest, + note: Note, + project: Project, + snippet: Snippet, + user: User + ).freeze + RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour delegate :name, :email, :public_email, :username, to: :author, prefix: true, allow_nil: true @@ -23,10 +47,10 @@ class Event < ActiveRecord::Base belongs_to :author, class_name: "User" belongs_to :project - belongs_to :target, polymorphic: true + belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations # For Hash only - serialize :data + serialize :data # rubocop:disable Cop/ActiverecordSerialize # Callbacks after_create :reset_project_activity @@ -55,6 +79,14 @@ class Event < ActiveRecord::Base def limit_recent(limit = 20, offset = nil) recent.limit(limit).offset(offset) end + + def actions + ACTIONS.keys + end + + def target_types + TARGET_TYPES.keys + end end def visible_to_user?(user = nil) diff --git a/app/models/group.rb b/app/models/group.rb index fac5843f75c..5bb2cdc5eff 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -4,6 +4,7 @@ class Group < Namespace include Gitlab::ConfigHelper include Gitlab::VisibilityLevel include AccessRequestable + include Avatarable include Referable include SelectForProjectAuthorization @@ -115,10 +116,10 @@ class Group < Namespace allowed_by_projects end - def avatar_url(size = nil) - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - end + def avatar_url(**args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) end def lfs_enabled? @@ -221,6 +222,16 @@ class Group < Namespace User.where(id: members_with_parents.select(:user_id)) end + def max_member_access_for_user(user) + return GroupMember::OWNER if user.admin? + + members_with_parents. + where(user_id: user). + reorder(access_level: :desc). + first&. + access_level || GroupMember::NO_ACCESS + end + def mattermost_team_params max_length = 59 diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index c631e7a7df5..ee6165fd32d 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -5,7 +5,7 @@ class ProjectHook < WebHook 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 :job_hooks, -> { where(job_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/service_hook.rb b/app/models/hooks/service_hook.rb index eef24052a06..40e43c27f91 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -2,6 +2,6 @@ class ServiceHook < WebHook belongs_to :service def execute(data) - super(data, 'service_hook') + WebHookService.new(self, data, 'service_hook').execute end end diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb index 777bad1e724..1584235ab00 100644 --- a/app/models/hooks/system_hook.rb +++ b/app/models/hooks/system_hook.rb @@ -1,5 +1,6 @@ class SystemHook < WebHook - def async_execute(data, hook_name) - Sidekiq::Client.enqueue(SystemHookWorker, id, data, hook_name) - end + scope :repository_update_hooks, -> { where(repository_update_events: true) } + + default_value_for :push_events, false + default_value_for :repository_update_events, true end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 595602e80fe..7503f3739c3 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -1,6 +1,5 @@ class WebHook < ActiveRecord::Base include Sortable - include HTTParty default_value_for :push_events, true default_value_for :issues_events, false @@ -8,56 +7,23 @@ class WebHook < ActiveRecord::Base 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 :job_events, false default_value_for :pipeline_events, false + default_value_for :repository_update_events, false default_value_for :enable_ssl_verification, true + has_many :web_hook_logs, dependent: :destroy + scope :push_hooks, -> { where(push_events: true) } scope :tag_push_hooks, -> { where(tag_push_events: true) } - # HTTParty timeout - default_timeout Gitlab.config.gitlab.webhook_timeout - validates :url, presence: true, url: true def execute(data, hook_name) - parsed_url = URI.parse(url) - if parsed_url.userinfo.blank? - response = WebHook.post(url, - body: data.to_json, - headers: build_headers(hook_name), - verify: enable_ssl_verification) - else - post_url = url.gsub("#{parsed_url.userinfo}@", '') - auth = { - username: CGI.unescape(parsed_url.user), - password: CGI.unescape(parsed_url.password), - } - response = WebHook.post(post_url, - body: data.to_json, - headers: build_headers(hook_name), - verify: enable_ssl_verification, - basic_auth: auth) - end - - [response.code, response.to_s] - rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout => e - logger.error("WebHook Error => #{e}") - [false, e.to_s] + WebHookService.new(self, data, hook_name).execute end def async_execute(data, hook_name) - Sidekiq::Client.enqueue(ProjectWebHookWorker, id, data, hook_name) - end - - private - - def build_headers(hook_name) - headers = { - 'Content-Type' => 'application/json', - 'X-Gitlab-Event' => hook_name.singularize.titleize - } - headers['X-Gitlab-Token'] = token if token.present? - headers + WebHookService.new(self, data, hook_name).async_execute end end diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb new file mode 100644 index 00000000000..d73cfcf630d --- /dev/null +++ b/app/models/hooks/web_hook_log.rb @@ -0,0 +1,13 @@ +class WebHookLog < ActiveRecord::Base + belongs_to :web_hook + + serialize :request_headers, Hash # rubocop:disable Cop/ActiverecordSerialize + serialize :request_data, Hash # rubocop:disable Cop/ActiverecordSerialize + serialize :response_headers, Hash # rubocop:disable Cop/ActiverecordSerialize + + validates :web_hook, presence: true + + def success? + response_status =~ /^2/ + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index 27e3ed9bc7f..693cc21bb40 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -174,7 +174,7 @@ class Issue < ActiveRecord::Base # Returns boolean if a related branch exists for the current issue # ignores merge requests branchs - def has_related_branch? + def has_related_branch? project.repository.branch_names.any? do |branch| /\A#{iid}-(?!\d+-stable)/i =~ branch end @@ -251,9 +251,9 @@ class Issue < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - json[:subscribed] = subscribed?(options[:user], project) if options.has_key?(:user) && options[:user] + json[:subscribed] = subscribed?(options[:user], project) if options.key?(:user) && options[:user] - if options.has_key?(:labels) + if options.key?(:labels) json[:labels] = labels.as_json( project: project, only: [:id, :title, :description, :color, :priority], @@ -292,7 +292,7 @@ class Issue < ActiveRecord::Base end def expire_etag_cache - key = Gitlab::Routing.url_helpers.rendered_title_namespace_project_issue_path( + key = Gitlab::Routing.url_helpers.realtime_changes_namespace_project_issue_path( project.namespace, project, self diff --git a/app/models/key.rb b/app/models/key.rb index 9c74ca84753..cb8f10f6d55 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -1,7 +1,6 @@ require 'digest/md5' class Key < ActiveRecord::Base - include AfterCommitQueue include Sortable LAST_USED_AT_REFRESH_TIME = 1.day.to_i @@ -25,10 +24,10 @@ class Key < ActiveRecord::Base delegate :name, :email, to: :user, prefix: true - after_create :add_to_shell - after_create :notify_user + after_commit :add_to_shell, on: :create + after_commit :notify_user, on: :create after_create :post_create_hook - after_destroy :remove_from_shell + after_commit :remove_from_shell, on: :destroy after_destroy :post_destroy_hook def key=(value) @@ -74,7 +73,7 @@ class Key < ActiveRecord::Base GitlabShellWorker.perform_async( :remove_key, shell_id, - key, + key ) end @@ -93,6 +92,6 @@ class Key < ActiveRecord::Base end def notify_user - run_after_commit { NotificationService.new.new_key(self) } + NotificationService.new.new_key(self) end end diff --git a/app/models/label.rb b/app/models/label.rb index ddddb6bdf8f..955d6b4079b 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -133,6 +133,10 @@ class Label < ActiveRecord::Base template end + def color + super || DEFAULT_COLOR + end + def text_color LabelsHelper.text_color_for_bg(self.color) end @@ -168,7 +172,7 @@ class Label < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - json[:priority] = priority(options[:project]) if options.has_key?(:project) + json[:priority] = priority(options[:project]) if options.key?(:project) end end diff --git a/app/models/label_link.rb b/app/models/label_link.rb index 51b5c2b1f4c..d68e1f54317 100644 --- a/app/models/label_link.rb +++ b/app/models/label_link.rb @@ -1,7 +1,7 @@ class LabelLink < ActiveRecord::Base include Importable - belongs_to :target, polymorphic: true + belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :label validates :target, presence: true, unless: :importing? diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index d7c627432d2..7126de2d488 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -7,7 +7,7 @@ class LegacyDiffNote < Note include NoteOnDiff - serialize :st_diff + serialize :st_diff # rubocop:disable Cop/ActiverecordSerialize validates :line_code, presence: true, line_code: true @@ -61,7 +61,7 @@ class LegacyDiffNote < Note return true if for_commit? return true unless diff_line return false unless noteable - return false if diff_refs && diff_refs != noteable_diff_refs + return false if diff_refs && diff_refs != noteable.diff_refs noteable_diff = find_noteable_diff diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb index 007eed5600a..b0625c52b62 100644 --- a/app/models/lfs_objects_project.rb +++ b/app/models/lfs_objects_project.rb @@ -6,8 +6,7 @@ class LfsObjectsProject < ActiveRecord::Base validates :lfs_object_id, uniqueness: { scope: [:project_id], message: "already exists in project" } validates :project_id, presence: true - after_create :update_project_statistics - after_destroy :update_project_statistics + after_commit :update_project_statistics, on: [:create, :destroy] private diff --git a/app/models/list.rb b/app/models/list.rb index fbd19acd1f5..918275be142 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -2,7 +2,7 @@ class List < ActiveRecord::Base belongs_to :board belongs_to :label - enum list_type: { label: 1, closed: 2 } + enum list_type: { backlog: 0, label: 1, closed: 2 } validates :board, :list_type, presence: true validates :label, :position, presence: true, if: :label? @@ -28,7 +28,7 @@ class List < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - if options.has_key?(:label) + if options.key?(:label) json[:label] = label.as_json( project: board.project, only: [:id, :title, :description, :color] diff --git a/app/models/member.rb b/app/models/member.rb index 7228e82e978..788a32dd8e3 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -8,7 +8,7 @@ class Member < ActiveRecord::Base belongs_to :created_by, class_name: "User" belongs_to :user - belongs_to :source, polymorphic: true + belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations delegate :name, :username, :email, to: :user, prefix: true @@ -200,6 +200,10 @@ class Member < ActiveRecord::Base source_type end + def access_field + access_level + end + def invite? self.invite_token.present? end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 28e10bc6172..47040f95533 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -25,10 +25,6 @@ class GroupMember < Member source end - def access_field - access_level - end - # Because source_type is `Namespace`... def real_source_type 'Group' diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index b3a91feb091..c0e17f4bfc8 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -79,10 +79,6 @@ class ProjectMember < Member end end - def access_field - access_level - end - def project source end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 2f3e4bf894a..dd155252ad5 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -13,13 +13,15 @@ class MergeRequest < ActiveRecord::Base has_one :merge_request_diff, -> { order('merge_request_diffs.id DESC') } + belongs_to :head_pipeline, foreign_key: "head_pipeline_id", class_name: "Ci::Pipeline" + has_many :events, as: :target, dependent: :destroy has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all belongs_to :assignee, class_name: "User" - serialize :merge_params, Hash + serialize :merge_params, Hash # rubocop:disable Cop/ActiverecordSerialize after_create :ensure_merge_request_diff, unless: :importing? after_update :reload_diff_if_branch_changed @@ -218,10 +220,10 @@ class MergeRequest < ActiveRecord::Base def diffs(diff_options = {}) if compare - # When saving MR diffs, `no_collapse` is implicitly added (because we need + # When saving MR diffs, `expanded` is implicitly added (because we need # to save the entire contents to the DB), so add that here for # consistency. - compare.diffs(diff_options.merge(no_collapse: true)) + compare.diffs(diff_options.merge(expanded: true)) else merge_request_diff.diffs(diff_options) end @@ -243,19 +245,6 @@ class MergeRequest < ActiveRecord::Base end end - # MRs created before 8.4 don't store a MergeRequestDiff#base_commit_sha, - # but we need to get a commit for the "View file @ ..." link by deleted files, - # so we find the likely one if we can't get the actual one. - # This will not be the actual base commit if the target branch was merged into - # the source branch after the merge request was created, but it is good enough - # for the specific purpose of linking to a commit. - # It is not good enough for use in `Gitlab::Git::DiffRefs`, which needs the - # true base commit, so we can't simply have `#diff_base_commit` fall back on - # this method. - def likely_diff_base_commit - first_commit.try(:parent) || first_commit - end - def diff_start_commit if persisted? merge_request_diff.start_commit @@ -320,21 +309,14 @@ class MergeRequest < ActiveRecord::Base end def diff_refs - return unless diff_start_commit || diff_base_commit - - Gitlab::Diff::DiffRefs.new( - base_sha: diff_base_sha, - start_sha: diff_start_sha, - head_sha: diff_head_sha - ) - end - - # 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? + if persisted? merge_request_diff.diff_refs else - diff_refs + Gitlab::Diff::DiffRefs.new( + base_sha: diff_base_sha, + start_sha: diff_start_sha, + head_sha: diff_head_sha + ) end end @@ -414,13 +396,24 @@ class MergeRequest < ActiveRecord::Base @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha] end + def version_params_for(diff_refs) + if diff = merge_request_diff_for(diff_refs) + { diff_id: diff.id } + elsif diff = merge_request_diff_for(diff_refs.head_sha) + { + diff_id: diff.id, + start_sha: diff_refs.start_sha + } + end + end + def reload_diff_if_branch_changed if source_branch_changed? || target_branch_changed? reload_diff end end - def reload_diff + def reload_diff(current_user = nil) return unless open? old_diff_refs = self.diff_refs @@ -428,9 +421,10 @@ class MergeRequest < ActiveRecord::Base MergeRequests::MergeRequestDiffCacheService.new.execute(self) new_diff_refs = self.diff_refs - update_diff_notes_positions( + update_diff_discussion_positions( old_diff_refs: old_diff_refs, - new_diff_refs: new_diff_refs + new_diff_refs: new_diff_refs, + current_user: current_user ) end @@ -823,12 +817,6 @@ class MergeRequest < ActiveRecord::Base diverged_commits_count > 0 end - def head_pipeline - return unless diff_head_sha && source_project - - @head_pipeline ||= source_project.pipeline_for(source_branch, diff_head_sha) - end - def all_pipelines return Ci::Pipeline.none unless source_project @@ -862,34 +850,30 @@ class MergeRequest < ActiveRecord::Base end def has_complete_diff_refs? - diff_sha_refs && diff_sha_refs.complete? + diff_refs && diff_refs.complete? end - def update_diff_notes_positions(old_diff_refs:, new_diff_refs:) + def update_diff_discussion_positions(old_diff_refs:, new_diff_refs:, current_user: nil) return unless has_complete_diff_refs? return if new_diff_refs == old_diff_refs - active_diff_notes = self.notes.new_diff_notes.select do |note| - note.active?(old_diff_refs) + active_diff_discussions = self.notes.new_diff_notes.discussions.select do |discussion| + discussion.active?(old_diff_refs) end + return if active_diff_discussions.empty? - return if active_diff_notes.empty? - - paths = active_diff_notes.flat_map { |n| n.diff_file.paths }.uniq + paths = active_diff_discussions.flat_map { |n| n.diff_file.paths }.uniq - service = Notes::DiffPositionUpdateService.new( + service = Discussions::UpdateDiffPositionService.new( self.project, - nil, + current_user, old_diff_refs: old_diff_refs, new_diff_refs: new_diff_refs, paths: paths ) - transaction do - active_diff_notes.each do |note| - service.execute(note) - Gitlab::Timeless.timeless(note, &:save) - end + active_diff_discussions.each do |discussion| + service.execute(discussion) end end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index f0a3c30ea74..99dd2130188 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -1,7 +1,7 @@ class MergeRequestDiff < ActiveRecord::Base include Sortable include Importable - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper # Prevent store of diff if commits amount more then 500 COMMITS_SAFE_SIZE = 100 @@ -11,8 +11,8 @@ class MergeRequestDiff < ActiveRecord::Base belongs_to :merge_request - serialize :st_commits - serialize :st_diffs + serialize :st_commits # rubocop:disable Cop/ActiverecordSerialize + serialize :st_diffs # rubocop:disable Cop/ActiverecordSerialize state_machine :state, initial: :empty do state :collected @@ -150,6 +150,29 @@ class MergeRequestDiff < ActiveRecord::Base ) end + # MRs created before 8.4 don't store their true diff refs (start and base), + # but we need to get a commit SHA for the "View file @ ..." link by a file, + # so we use an approximation of the diff refs if we can't get the actual one. + # + # These will not be the actual diff refs if the target branch was merged into + # the source branch after the merge request was created, but it is good enough + # for the specific purpose of linking to a commit. + # + # It is not good enough for highlighting diffs, so we can't simply pass + # these as `diff_refs.` + def fallback_diff_refs + real_refs = diff_refs + return real_refs if real_refs + + likely_base_commit_sha = (first_commit&.parent || first_commit)&.sha + + Gitlab::Diff::DiffRefs.new( + base_sha: likely_base_commit_sha, + start_sha: safe_start_commit_sha, + head_sha: head_commit_sha + ) + end + def diff_refs_by_sha? base_commit_sha? && head_commit_sha? && start_commit_sha? end @@ -175,12 +198,11 @@ class MergeRequestDiff < ActiveRecord::Base self == merge_request.merge_request_diff end - def compare_with(sha, straight: true) + def compare_with(sha) # 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(project, head_commit_sha) - .execute(project, sha, straight: straight) + CompareService.new(project, head_commit_sha).execute(project, sha, straight: true) end def commits_count diff --git a/app/models/milestone.rb b/app/models/milestone.rb index c06bfe0ccdd..b04bed4c014 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -107,7 +107,7 @@ class Milestone < ActiveRecord::Base end def participants - User.joins(assigned_issues: :milestone).where("milestones.id = ?", id) + User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).uniq end def self.sort(method) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 9bdfab9a066..b48d73dcae7 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -6,6 +6,7 @@ class Namespace < ActiveRecord::Base include Gitlab::ShellAdapter include Gitlab::CurrentSettings include Routable + include AfterCommitQueue # Prevent users from creating unreasonably deep level of nesting. # The number 20 was taken based on maximum nesting level of @@ -56,7 +57,7 @@ class Namespace < ActiveRecord::Base 'COALESCE(SUM(ps.storage_size), 0) AS storage_size', 'COALESCE(SUM(ps.repository_size), 0) AS repository_size', 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size', - 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size', + 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size' ) end @@ -242,7 +243,9 @@ class Namespace < ActiveRecord::Base # Remove namespace directroy async with delay so # GitLab has time to remove all projects first - GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path) + run_after_commit do + GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path) + end end end diff --git a/app/models/note.rb b/app/models/note.rb index 4cb3c6f062a..244bf169c29 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -41,7 +41,7 @@ class Note < ActiveRecord::Base participant :author belongs_to :project - belongs_to :noteable, polymorphic: true, touch: true + belongs_to :noteable, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :author, class_name: "User" belongs_to :updated_by, class_name: "User" belongs_to :last_edited_by, class_name: 'User' @@ -111,7 +111,7 @@ class Note < ActiveRecord::Base end def discussions(context_noteable = nil) - Discussion.build_collection(fresh, context_noteable) + Discussion.build_collection(all.includes(:noteable).fresh, context_noteable) end def find_discussion(discussion_id) @@ -125,13 +125,12 @@ class Note < ActiveRecord::Base groups = {} diff_notes.fresh.discussions.each do |discussion| - if discussion.active?(diff_refs) - discussions = groups[discussion.line_code] ||= [] - elsif diff_refs && discussion.created_at_diff?(diff_refs) - discussions = groups[discussion.original_line_code] ||= [] - end + line_code = discussion.line_code_in_diffs(diff_refs) - discussions << discussion if discussions + if line_code + discussions = groups[line_code] ||= [] + discussions << discussion + end end groups diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index e4726e62e93..42412a9a44b 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -4,7 +4,7 @@ class NotificationSetting < ActiveRecord::Base default_value_for :level, NotificationSetting.levels[:global] belongs_to :user - belongs_to :source, polymorphic: true + belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :project, foreign_key: 'source_id' validates :user, presence: true diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index f2f2fc1e32a..5d798247863 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -1,7 +1,7 @@ class PagesDomain < ActiveRecord::Base belongs_to :project - validates :domain, hostname: true + validates :domain, hostname: { allow_numeric_hostname: true } validates :domain, uniqueness: { case_sensitive: false } validates :certificate, certificate: true, allow_nil: true, allow_blank: true validates :key, certificate_key: true, allow_nil: true, allow_blank: true @@ -98,7 +98,7 @@ class PagesDomain < ActiveRecord::Base def validate_pages_domain return unless domain - if domain.downcase.ends_with?(".#{Settings.pages.host}".downcase) + if domain.downcase.ends_with?(Settings.pages.host.downcase) self.errors.add(:domain, "*.#{Settings.pages.host} is restricted") end end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index e8b000ddad6..6e13f9b2089 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -3,7 +3,7 @@ class PersonalAccessToken < ActiveRecord::Base include TokenAuthenticatable add_authentication_token_field :token - serialize :scopes, Array + serialize :scopes, Array # rubocop:disable Cop/ActiverecordSerialize belongs_to :user @@ -15,11 +15,10 @@ class PersonalAccessToken < ActiveRecord::Base scope :without_impersonation, -> { where(impersonation: false) } validates :scopes, presence: true - validate :validate_api_scopes + validate :validate_scopes def revoke! - self.revoked = true - self.save + update!(revoked: true) end def active? @@ -28,9 +27,9 @@ class PersonalAccessToken < ActiveRecord::Base protected - def validate_api_scopes - unless scopes.all? { |scope| Gitlab::Auth::API_SCOPES.include?(scope.to_sym) } - errors.add :scopes, "can only contain API scopes" + def validate_scopes + unless scopes.all? { |scope| Gitlab::Auth::AVAILABLE_SCOPES.include?(scope.to_sym) } + errors.add :scopes, "can only contain available scopes" end end end diff --git a/app/models/project.rb b/app/models/project.rb index 7722db5375b..9278febbb58 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -6,6 +6,7 @@ class Project < ActiveRecord::Base include Gitlab::VisibilityLevel include Gitlab::CurrentSettings include AccessRequestable + include Avatarable include CacheMarkdownField include Referable include Sortable @@ -164,7 +165,7 @@ class Project < ActiveRecord::Base has_many :todos, dependent: :destroy has_many :notification_settings, dependent: :destroy, as: :source - has_one :import_data, dependent: :destroy, class_name: "ProjectImportData" + has_one :import_data, dependent: :delete, class_name: "ProjectImportData" has_one :project_feature, dependent: :destroy has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete has_many :container_repositories, dependent: :destroy @@ -174,7 +175,7 @@ class Project < ActiveRecord::Base has_many :builds, class_name: 'Ci::Build' # the builds are created from the commit_statuses has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject' has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' - has_many :variables, dependent: :destroy, class_name: 'Ci::Variable' + has_many :variables, class_name: 'Ci::Variable' has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger' has_many :environments, dependent: :destroy has_many :deployments, dependent: :destroy @@ -204,8 +205,13 @@ class Project < ActiveRecord::Base presence: true, dynamic_path: true, length: { maximum: 255 }, +<<<<<<< HEAD format: { with: Gitlab::Regex.project_path_format_regex, message: Gitlab::Regex.project_path_regex_message }, +======= + format: { with: Gitlab::PathRegex.project_path_format_regex, + message: Gitlab::PathRegex.project_path_format_message }, +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 uniqueness: { scope: :namespace_id } validates :namespace, presence: true @@ -241,6 +247,7 @@ class Project < ActiveRecord::Base scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } scope :personal, ->(user) { where(namespace_id: user.namespace_id) } scope :joined, ->(user) { where('namespace_id != ?', user.namespace_id) } + scope :starred_by, ->(user) { joins(:users_star_projects).where('users_star_projects.user_id': user.id) } scope :visible_to_user, ->(user) { where(id: user.authorized_projects.select(:id).reorder(nil)) } scope :non_archived, -> { where(archived: false) } scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct } @@ -270,6 +277,7 @@ class Project < ActiveRecord::Base scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } + scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) } enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } @@ -295,8 +303,16 @@ class Project < ActiveRecord::Base scope :excluding_project, ->(project) { where.not(id: project) } state_machine :import_status, initial: :none do + event :import_schedule do + transition [:none, :finished, :failed] => :scheduled + end + + event :force_import_start do + transition [:none, :finished, :failed] => :started + end + event :import_start do - transition [:none, :finished] => :started + transition scheduled: :started end event :import_finish do @@ -304,18 +320,23 @@ class Project < ActiveRecord::Base end event :import_fail do - transition started: :failed + transition [:scheduled, :started] => :failed end event :import_retry do transition failed: :started end + state :scheduled state :started state :finished state :failed - after_transition any => :finished, do: :reset_cache_and_import_attrs + after_transition [:none, :finished, :failed] => :scheduled do |project, _| + project.run_after_commit { add_import_job } + end + + after_transition started: :finished, do: :reset_cache_and_import_attrs end class << self @@ -348,10 +369,6 @@ class Project < ActiveRecord::Base where("projects.id IN (#{union.to_sql})") end - def search_by_visibility(level) - where(visibility_level: Gitlab::VisibilityLevel.string_options[level]) - end - def search_by_title(query) pattern = "%#{query}%" table = Project.arel_table @@ -379,11 +396,9 @@ class Project < ActiveRecord::Base end def reference_pattern - name_pattern = Gitlab::Regex::FULL_NAMESPACE_REGEX_STR - %r{ - ((?<namespace>#{name_pattern})\/)? - (?<project>#{name_pattern}) + ((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)? + (?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX}) }x end @@ -474,7 +489,9 @@ class Project < ActiveRecord::Base end def reset_cache_and_import_attrs - ProjectCacheWorker.perform_async(self.id) + run_after_commit do + ProjectCacheWorker.perform_async(self.id) + end self.import_data&.destroy end @@ -533,9 +550,17 @@ class Project < ActiveRecord::Base end def import_in_progress? + import_started? || import_scheduled? + end + + def import_started? import? && import_status == 'started' end + def import_scheduled? + import_status == 'scheduled' + end + def import_failed? import_status == 'failed' end @@ -798,12 +823,10 @@ class Project < ActiveRecord::Base repository.avatar end - def avatar_url - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - elsif avatar_in_git - Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self) - end + def avatar_url(**args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) || (Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self) if avatar_in_git) end # For compatibility with old code @@ -876,10 +899,8 @@ class Project < ActiveRecord::Base url_to_repo end - def http_url_to_repo(user = nil) - credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user) - - Gitlab::UrlSanitizer.new("#{web_url}.git", credentials: credentials).full_url + def http_url_to_repo + "#{web_url}.git" end def user_can_push_to_empty_repo?(user) @@ -968,7 +989,7 @@ class Project < ActiveRecord::Base namespace: namespace.name, visibility_level: visibility_level, path_with_namespace: path_with_namespace, - default_branch: default_branch, + default_branch: default_branch } # Backward compatibility @@ -1068,11 +1089,6 @@ class Project < ActiveRecord::Base pipelines.order(id: :desc).find_by(sha: sha, ref: ref) end - 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 project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED) end @@ -1238,6 +1254,7 @@ class Project < ActiveRecord::Base { key: 'CI_PROJECT_ID', value: id.to_s, public: true }, { key: 'CI_PROJECT_NAME', value: path, public: true }, { key: 'CI_PROJECT_PATH', value: path_with_namespace, public: true }, + { key: 'CI_PROJECT_PATH_SLUG', value: path_with_namespace.parameterize, public: true }, { key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path, public: true }, { key: 'CI_PROJECT_URL', value: web_url, public: true } ] @@ -1257,12 +1274,19 @@ class Project < ActiveRecord::Base variables end - def secret_variables - variables.map do |variable| - { key: variable.key, value: variable.value, public: false } + def secret_variables_for(ref) + if protected_for?(ref) + variables + else + variables.unprotected end end + def protected_for?(ref) + ProtectedBranch.protected?(self, ref) || + ProtectedTag.protected?(self, ref) + end + def deployment_variables return [] unless deployment_service diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb index 331123a5a5b..e3cafd4d1c6 100644 --- a/app/models/project_import_data.rb +++ b/app/models/project_import_data.rb @@ -10,7 +10,7 @@ class ProjectImportData < ActiveRecord::Base insecure_mode: true, algorithm: 'aes-256-cbc' - serialize :data, JSON + serialize :data, JSON # rubocop:disable Cop/ActiverecordSerialize validates :project, presence: true diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb index 3728f5642e4..9ce2d1153a7 100644 --- a/app/models/project_services/asana_service.rb +++ b/app/models/project_services/asana_service.rb @@ -34,7 +34,8 @@ http://app.asana.com/-/account_api' { type: 'text', name: 'api_key', - placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.' + placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.', + required: true }, { type: 'text', diff --git a/app/models/project_services/assembla_service.rb b/app/models/project_services/assembla_service.rb index aeeff8917bf..ae6af732ed4 100644 --- a/app/models/project_services/assembla_service.rb +++ b/app/models/project_services/assembla_service.rb @@ -18,7 +18,7 @@ class AssemblaService < Service def fields [ - { type: 'text', name: 'token', placeholder: '' }, + { type: 'text', name: 'token', placeholder: '', required: true }, { type: 'text', name: 'subdomain', placeholder: '' } ] end diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index 400020ee04a..42939ea0ec8 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -47,12 +47,12 @@ class BambooService < CiService def fields [ { type: 'text', name: 'bamboo_url', - placeholder: 'Bamboo root URL like https://bamboo.example.com' }, + placeholder: 'Bamboo root URL like https://bamboo.example.com', required: true }, { type: 'text', name: 'build_key', - placeholder: 'Bamboo build plan key like KEY' }, + placeholder: 'Bamboo build plan key like KEY', required: true }, { type: 'text', name: 'username', placeholder: 'A user with API access, if applicable' }, - { type: 'password', name: 'password' }, + { type: 'password', name: 'password' } ] end diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb index 5fb95050b83..fc30f6e3365 100644 --- a/app/models/project_services/buildkite_service.rb +++ b/app/models/project_services/buildkite_service.rb @@ -58,11 +58,11 @@ class BuildkiteService < CiService [ { type: 'text', name: 'token', - placeholder: 'Buildkite project GitLab token' }, + placeholder: 'Buildkite project GitLab token', required: true }, { type: 'text', name: 'project_url', - placeholder: "#{ENDPOINT}/example/project" }, + placeholder: "#{ENDPOINT}/example/project", required: true }, { type: 'checkbox', name: 'enable_ssl_verification', diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb index 0de59af5652..c3f5b310619 100644 --- a/app/models/project_services/campfire_service.rb +++ b/app/models/project_services/campfire_service.rb @@ -18,7 +18,7 @@ class CampfireService < Service def fields [ - { type: 'text', name: 'token', placeholder: '' }, + { type: 'text', name: 'token', placeholder: '', required: true }, { type: 'text', name: 'subdomain', placeholder: '' }, { type: 'text', name: 'room', placeholder: '' } ] @@ -76,7 +76,7 @@ class CampfireService < Service # 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 = self.class.get("/rooms.json", auth) res.code == 200 ? res["rooms"] : [] end diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index 47b68f00cff..3edc395033c 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -35,7 +35,7 @@ module ChatMessage def activity { - title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}", + title: "Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status}", subtitle: "in #{project_link}", text: "in #{pretty_duration(duration)}", image: user_avatar || '' @@ -45,7 +45,7 @@ module ChatMessage private def message - "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}" + "#{project_link}: Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}" end def humanized_status @@ -70,7 +70,7 @@ module ChatMessage end def branch_link - "[#{ref}](#{branch_url})" + "`[#{ref}](#{branch_url})`" end def project_link diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb index c52dd6ef8ef..04a59d559ca 100644 --- a/app/models/project_services/chat_message/push_message.rb +++ b/app/models/project_services/chat_message/push_message.rb @@ -61,7 +61,7 @@ module ChatMessage end def removed_branch_message - "#{user_name} removed #{ref_type} #{ref} from #{project_link}" + "#{user_name} removed #{ref_type} `#{ref}` from #{project_link}" end def push_message @@ -102,7 +102,7 @@ module ChatMessage end def branch_link - "[#{ref}](#{branch_url})" + "`[#{ref}](#{branch_url})`" end def project_link diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 6464bf3f4a4..6d1a321f651 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -21,10 +21,6 @@ class ChatNotificationService < Service end end - def can_test? - valid? - end - def self.supported_events %w[push issue confidential_issue merge_request note tag_push pipeline wiki_page] @@ -36,10 +32,10 @@ class ChatNotificationService < Service def default_fields [ - { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, + { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true }, { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'checkbox', name: 'notify_only_default_branch' }, + { type: 'checkbox', name: 'notify_only_default_branch' } ] end diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb index dea915a4d05..b9e3e982b64 100644 --- a/app/models/project_services/custom_issue_tracker_service.rb +++ b/app/models/project_services/custom_issue_tracker_service.rb @@ -31,9 +31,9 @@ class CustomIssueTrackerService < IssueTrackerService [ { type: 'text', name: 'title', placeholder: title }, { type: 'text', name: 'description', placeholder: description }, - { type: 'text', name: 'project_url', placeholder: 'Project url' }, - { type: 'text', name: 'issues_url', placeholder: 'Issue url' }, - { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url' } + { type: 'text', name: 'project_url', placeholder: 'Project url', required: true }, + { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true }, + { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true } ] end end diff --git a/app/models/project_services/deployment_service.rb b/app/models/project_services/deployment_service.rb index 91a55514a9a..5b8320158fc 100644 --- a/app/models/project_services/deployment_service.rb +++ b/app/models/project_services/deployment_service.rb @@ -30,4 +30,8 @@ class DeploymentService < Service def terminals(environment) raise NotImplementedError end + + def can_test? + false + end end diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb index 2717c240f05..f6cade9c290 100644 --- a/app/models/project_services/drone_ci_service.rb +++ b/app/models/project_services/drone_ci_service.rb @@ -93,8 +93,8 @@ class DroneCiService < CiService def fields [ - { type: 'text', name: 'token', placeholder: 'Drone CI project specific token' }, - { type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com' }, + { type: 'text', name: 'token', placeholder: 'Drone CI project specific token', required: true }, + { type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com', required: true }, { type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" } ] end diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index f4f913ee0b6..1a236e232f9 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -47,7 +47,7 @@ class EmailsOnPushService < Service help: "Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. #{domains})." }, { type: 'checkbox', name: 'disable_diffs', title: "Disable code diffs", help: "Don't include possibly sensitive code diffs in notification body." }, - { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' }, + { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' } ] end end diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb index bdf6fa6a586..720ad61162e 100644 --- a/app/models/project_services/external_wiki_service.rb +++ b/app/models/project_services/external_wiki_service.rb @@ -19,7 +19,7 @@ class ExternalWikiService < Service def fields [ - { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki' }, + { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki', required: true } ] end diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb index 10a13c3fbdc..2db95b9aaa3 100644 --- a/app/models/project_services/flowdock_service.rb +++ b/app/models/project_services/flowdock_service.rb @@ -18,7 +18,7 @@ class FlowdockService < Service def fields [ - { type: 'text', name: 'token', placeholder: 'Flowdock Git source token' } + { type: 'text', name: 'token', placeholder: 'Flowdock Git source token', required: true } ] end @@ -37,7 +37,7 @@ class FlowdockService < Service repo: project.repository.path_to_repo, repo_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}", commit_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/commit/%s", - diff_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/compare/%s...%s", + diff_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/compare/%s...%s" ) end end diff --git a/app/models/project_services/gemnasium_service.rb b/app/models/project_services/gemnasium_service.rb index f271e1f1739..017a9b2df6e 100644 --- a/app/models/project_services/gemnasium_service.rb +++ b/app/models/project_services/gemnasium_service.rb @@ -18,8 +18,8 @@ class GemnasiumService < Service def fields [ - { type: 'text', name: 'api_key', placeholder: 'Your personal API KEY on gemnasium.com ' }, - { type: 'text', name: 'token', placeholder: 'The project\'s slug on gemnasium.com' } + { type: 'text', name: 'api_key', placeholder: 'Your personal API KEY on gemnasium.com ', required: true }, + { type: 'text', name: 'token', placeholder: 'The project\'s slug on gemnasium.com', required: true } ] end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index 8b181221bb0..e3906943ecd 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -33,7 +33,7 @@ class HipchatService < Service def fields [ - { type: 'text', name: 'token', placeholder: 'Room token' }, + { type: 'text', name: 'token', placeholder: 'Room token', required: true }, { type: 'text', name: 'room', placeholder: 'Room name or ID' }, { type: 'checkbox', name: 'notify' }, { type: 'select', name: 'color', choices: %w(yellow red green purple gray random) }, @@ -41,7 +41,7 @@ class HipchatService < Service placeholder: 'Leave blank for default (v2)' }, { type: 'text', name: 'server', placeholder: 'Leave blank for default. https://hipchat.example.com' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' } ] end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index c62bb4fa120..19357f90810 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -49,7 +49,7 @@ class IrkerService < Service help: 'A default IRC URI to prepend before each recipient (optional)', placeholder: 'irc://irc.network.net:6697/' }, { type: 'textarea', name: 'recipients', - placeholder: 'Recipients/channels separated by whitespaces', + placeholder: 'Recipients/channels separated by whitespaces', required: true, help: 'Recipients have to be specified with a full URI: '\ 'irc[s]://irc.network.net[:port]/#channel. Special cases: if '\ 'you want the channel to be a nickname instead, append ",isnick" to ' \ @@ -58,7 +58,7 @@ class IrkerService < Service ' want to use a password, you have to omit the "#" on the channel). If you ' \ ' specify a default IRC URI to prepend before each recipient, you can just ' \ ' give a channel name.' }, - { type: 'checkbox', name: 'colorize_messages' }, + { type: 'checkbox', name: 'colorize_messages' } ] end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 50435b67eda..ff138b9066d 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -32,9 +32,9 @@ class IssueTrackerService < Service def fields [ { type: 'text', name: 'description', placeholder: description }, - { type: 'text', name: 'project_url', placeholder: 'Project url' }, - { type: 'text', name: 'issues_url', placeholder: 'Issue url' }, - { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url' } + { type: 'text', name: 'project_url', placeholder: 'Project url', required: true }, + { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true }, + { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true } ] end @@ -76,7 +76,7 @@ class IssueTrackerService < Service message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" result = true end - rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED => error + rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}" end Rails.logger.info(message) diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 97e997d3899..2450fb43212 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -2,9 +2,10 @@ class JiraService < IssueTrackerService include Gitlab::Routing.url_helpers validates :url, url: true, presence: true, if: :activated? + validates :api_url, url: true, allow_blank: true validates :project_key, presence: true, if: :activated? - prop_accessor :username, :password, :url, :project_key, + prop_accessor :username, :password, :url, :api_url, :project_key, :jira_issue_transition_id, :title, :description before_update :reset_password @@ -25,20 +26,18 @@ class JiraService < IssueTrackerService super do self.properties = { title: issues_tracker['title'], - url: issues_tracker['url'] + url: issues_tracker['url'], + api_url: issues_tracker['api_url'] } end end def reset_password - # don't reset the password if a new one is provided - if url_changed? && !password_touched? - self.password = nil - end + self.password = nil if reset_password? end def options - url = URI.parse(self.url) + url = URI.parse(client_url) { username: self.username, @@ -87,10 +86,11 @@ class JiraService < IssueTrackerService def fields [ - { type: 'text', name: 'url', title: 'URL', placeholder: 'https://jira.example.com' }, - { type: 'text', name: 'project_key', placeholder: 'Project Key' }, - { type: 'text', name: 'username', placeholder: '' }, - { type: 'password', name: 'password', placeholder: '' }, + { type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com', required: true }, + { type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' }, + { type: 'text', name: 'project_key', placeholder: 'Project Key', required: true }, + { type: 'text', name: 'username', placeholder: '', required: true }, + { type: 'password', name: 'password', placeholder: '', required: true }, { type: 'text', name: 'jira_issue_transition_id', placeholder: '' } ] end @@ -149,7 +149,7 @@ class JiraService < IssueTrackerService data = { user: { name: author.name, - url: resource_url(user_path(author)), + url: resource_url(user_path(author)) }, project: { name: self.project.path_with_namespace, @@ -175,10 +175,6 @@ class JiraService < IssueTrackerService { success: result.present?, result: result } end - def can_test? - username.present? && password.present? - end - # JIRA does not need test data. # We are requesting the project that belongs to the project key. def test_data(user = nil, project = nil) @@ -186,7 +182,7 @@ class JiraService < IssueTrackerService end def test_settings - return unless url.present? + return unless client_url.present? # Test settings by getting the project jira_request { jira_project.present? } end @@ -236,20 +232,29 @@ class JiraService < IssueTrackerService end def send_message(issue, message, remote_link_props) - return unless url.present? + return unless client_url.present? jira_request do - if issue.comments.build.save!(body: message) - remote_link = issue.remotelink.build + remote_link = find_remote_link(issue, remote_link_props[:object][:url]) + if remote_link remote_link.save!(remote_link_props) - result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}." + elsif issue.comments.build.save!(body: message) + new_remote_link = issue.remotelink.build + new_remote_link.save!(remote_link_props) end + result_message = "#{self.class.name} SUCCESS: Successfully posted to #{client_url}." Rails.logger.info(result_message) result_message end end + def find_remote_link(issue, url) + links = jira_request { issue.remotelink.all } + + links.find { |link| link.object["url"] == url } + end + def build_remote_link_props(url:, title:, resolved: false) status = { resolved: resolved @@ -294,8 +299,21 @@ class JiraService < IssueTrackerService def jira_request yield - rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError => e - Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" + rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e + Rails.logger.info "#{self.class.name} Send message ERROR: #{client_url} - #{e.message}" nil end + + def client_url + api_url.present? ? api_url : url + end + + def reset_password? + # don't reset the password if a new one is provided + return false if password_touched? + return true if api_url_changed? + return false if api_url.present? + + url_changed? + end end diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index 9c56518c991..8977a7cdafe 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -73,10 +73,18 @@ class KubernetesService < DeploymentService { type: 'textarea', name: 'ca_pem', title: 'Custom CA bundle', - placeholder: 'Certificate Authority bundle (PEM format)' }, + placeholder: 'Certificate Authority bundle (PEM format)' } ] end + def actual_namespace + if namespace.present? + namespace + else + default_namespace + end + end + # Check we can connect to the Kubernetes API def test(*args) kubeclient = build_kubeclient! @@ -91,7 +99,7 @@ class KubernetesService < DeploymentService variables = [ { key: 'KUBE_URL', value: api_url, public: true }, { key: 'KUBE_TOKEN', value: token, public: false }, - { key: 'KUBE_NAMESPACE', value: namespace_variable, public: true } + { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true } ] if ca_pem.present? @@ -110,7 +118,7 @@ class KubernetesService < DeploymentService with_reactive_cache do |data| pods = data.fetch(:pods, nil) filter_pods(pods, app: environment.slug). - flat_map { |pod| terminals_for_pod(api_url, namespace, pod) }. + flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }. each { |terminal| add_terminal_auth(terminal, terminal_auth) } end end @@ -124,7 +132,7 @@ class KubernetesService < DeploymentService # Store as hashes, rather than as third-party types pods = begin - kubeclient.get_pods(namespace: namespace).as_json + kubeclient.get_pods(namespace: actual_namespace).as_json rescue KubeException => err raise err unless err.error_code == 404 [] @@ -142,20 +150,12 @@ class KubernetesService < DeploymentService default_namespace || TEMPLATE_PLACEHOLDER end - def namespace_variable - if namespace.present? - namespace - else - default_namespace - end - end - def default_namespace "#{project.path}-#{project.id}" if project.present? end def build_kubeclient!(api_path: 'api', api_version: 'v1') - raise "Incomplete settings" unless api_url && namespace && token + raise "Incomplete settings" unless api_url && actual_namespace && token ::Kubeclient::Client.new( join_api_url(api_path), diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb index 9b218fd81b4..2facff53e26 100644 --- a/app/models/project_services/microsoft_teams_service.rb +++ b/app/models/project_services/microsoft_teams_service.rb @@ -35,7 +35,7 @@ class MicrosoftTeamsService < ChatNotificationService [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'checkbox', name: 'notify_only_default_branch' }, + { type: 'checkbox', name: 'notify_only_default_branch' } ] end diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb index a8d581a1f67..72ddf9a4be3 100644 --- a/app/models/project_services/mock_ci_service.rb +++ b/app/models/project_services/mock_ci_service.rb @@ -21,7 +21,8 @@ class MockCiService < CiService [ { type: 'text', name: 'mock_service_url', - placeholder: 'http://localhost:4004' }, + placeholder: 'http://localhost:4004', + required: true } ] end @@ -79,4 +80,8 @@ class MockCiService < CiService :error end end + + def can_test? + false + end end diff --git a/app/models/project_services/mock_monitoring_service.rb b/app/models/project_services/mock_monitoring_service.rb index dd04e04e198..ed0318c6b27 100644 --- a/app/models/project_services/mock_monitoring_service.rb +++ b/app/models/project_services/mock_monitoring_service.rb @@ -14,4 +14,8 @@ class MockMonitoringService < MonitoringService def metrics(environment) JSON.parse(File.read(Rails.root + 'spec/fixtures/metrics.json')) end + + def can_test? + false + end end diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb index 59776552540..17dba6fbbea 100644 --- a/app/models/project_services/monitoring_service.rb +++ b/app/models/project_services/monitoring_service.rb @@ -9,8 +9,16 @@ class MonitoringService < Service %w() end +<<<<<<< HEAD # Environments have a number of metrics def metrics(environment, timeframe_start: nil, timeframe_end: nil) +======= + def environment_metrics(environment) + raise NotImplementedError + end + + def deployment_metrics(deployment) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 raise NotImplementedError end end diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index ac617f409d9..9d37184be2c 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -53,9 +53,10 @@ class PipelinesEmailService < Service [ { type: 'textarea', name: 'recipients', - placeholder: 'Emails separated by comma' }, + placeholder: 'Emails separated by comma', + required: true }, { type: 'checkbox', - name: 'notify_only_broken_pipelines' }, + name: 'notify_only_broken_pipelines' } ] end diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb index d86f4f6f448..f9dfa2e91c3 100644 --- a/app/models/project_services/pivotaltracker_service.rb +++ b/app/models/project_services/pivotaltracker_service.rb @@ -23,7 +23,8 @@ class PivotaltrackerService < Service { type: 'text', name: 'token', - placeholder: 'Pivotal Tracker API token.' + placeholder: 'Pivotal Tracker API token.', + required: true }, { type: 'text', diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 6a4479c4dbc..b00c452c218 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -49,7 +49,8 @@ class PrometheusService < MonitoringService type: 'text', name: 'api_url', title: 'API URL', - placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/' + placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/', + required: true } ] end @@ -63,6 +64,7 @@ class PrometheusService < MonitoringService { success: false, result: err } end +<<<<<<< HEAD def metrics(environment, timeframe_start: nil, timeframe_end: nil) with_reactive_cache(environment.slug, timeframe_start, timeframe_end) do |data| data @@ -94,14 +96,33 @@ class PrometheusService < MonitoringService cpu_current: client.query(cpu_query, time: timeframe_end), cpu_previous: client.query(cpu_query, time: timeframe_start) }, +======= + def environment_metrics(environment) + with_reactive_cache(Gitlab::Prometheus::Queries::EnvironmentQuery.name, environment.id, &:itself) + end + + def deployment_metrics(deployment) + metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.id, &:itself) + metrics&.merge(deployment_time: created_at.to_i) || {} + end + + # Cache metrics for specific environment + def calculate_reactive_cache(query_class_name, *args) + return unless active? && project && !project.pending_delete? + + metrics = Kernel.const_get(query_class_name).new(client).query(*args) + + { + success: true, + metrics: metrics, +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 last_update: Time.now.utc } - rescue Gitlab::PrometheusError => err { success: false, result: err.message } end def client - @prometheus ||= Gitlab::Prometheus.new(api_url: api_url) + @prometheus ||= Gitlab::PrometheusClient.new(api_url: api_url) end end diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb index 3e618a8dbf1..aa7bd4c3c84 100644 --- a/app/models/project_services/pushover_service.rb +++ b/app/models/project_services/pushover_service.rb @@ -19,10 +19,10 @@ class PushoverService < Service def fields [ - { type: 'text', name: 'api_key', placeholder: 'Your application key' }, - { type: 'text', name: 'user_key', placeholder: 'Your user key' }, + { type: 'text', name: 'api_key', placeholder: 'Your application key', required: true }, + { type: 'text', name: 'user_key', placeholder: 'Your user key', required: true }, { type: 'text', name: 'device', placeholder: 'Leave blank for all active devices' }, - { type: 'select', name: 'priority', choices: + { type: 'select', name: 'priority', required: true, choices: [ ['Lowest Priority', -2], ['Low Priority', -1], @@ -55,7 +55,7 @@ class PushoverService < Service ['Pushover Echo (long)', 'echo'], ['Up Down (long)', 'updown'], ['None (silent)', 'none'] - ] }, + ] } ] end diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb index cbaffb8ce48..cbe137452bd 100644 --- a/app/models/project_services/teamcity_service.rb +++ b/app/models/project_services/teamcity_service.rb @@ -50,12 +50,12 @@ class TeamcityService < CiService def fields [ { type: 'text', name: 'teamcity_url', - placeholder: 'TeamCity root URL like https://teamcity.example.com' }, + placeholder: 'TeamCity root URL like https://teamcity.example.com', required: true }, { type: 'text', name: 'build_type', - placeholder: 'Build configuration ID' }, + placeholder: 'Build configuration ID', required: true }, { type: 'text', name: 'username', placeholder: 'A user with permissions to trigger a manual build' }, - { type: 'password', name: 'password' }, + { type: 'password', name: 'password' } ] end @@ -78,7 +78,7 @@ class TeamcityService < CiService auth = { username: username, - password: password, + password: password } branch = Gitlab::Git.ref_name(data[:ref]) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 543b9b293e0..e1cc56551ba 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -167,7 +167,7 @@ class ProjectTeam access = RequestStore.store[key] end - # Lookup only the IDs we need + # Look up only the IDs we need user_ids = user_ids - access.keys return access if user_ids.empty? @@ -178,6 +178,13 @@ class ProjectTeam maximum(:access_level) access.merge!(users_access) + + missing_user_ids = user_ids - users_access.keys + + missing_user_ids.each do |user_id| + access[user_id] = Gitlab::Access::NO_ACCESS + end + access end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 189c106b70b..f38fbda7839 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -42,11 +42,8 @@ class ProjectWiki url_to_repo end - def http_url_to_repo(user = nil) - url = "#{Gitlab.config.gitlab.url}/#{path_with_namespace}.git" - credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user) - - Gitlab::UrlSanitizer.new(url, credentials: credentials).full_url + def http_url_to_repo + "#{Gitlab.config.gitlab.url}/#{path_with_namespace}.git" end def wiki_base_path diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 28b7d5ad072..5f0d0802ac9 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -2,14 +2,7 @@ class ProtectedBranch < ActiveRecord::Base include Gitlab::ShellAdapter include ProtectedRef - has_many :merge_access_levels, dependent: :destroy - has_many :push_access_levels, dependent: :destroy - - validates :merge_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." } - validates :push_access_levels, length: { 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 + protected_ref_access_levels :merge, :push # Check if branch name is marked as protected in the system def self.protected?(project, ref_name) diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb index 771e3376613..e8d35ac326f 100644 --- a/app/models/protected_branch/merge_access_level.rb +++ b/app/models/protected_branch/merge_access_level.rb @@ -1,13 +1,3 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base include ProtectedBranchAccess - - validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, - Gitlab::Access::DEVELOPER] } - - def self.human_access_levels - { - Gitlab::Access::MASTER => "Masters", - Gitlab::Access::DEVELOPER => "Developers + Masters" - }.with_indifferent_access - end end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index 14610cb42b7..7a2e9e5ec5d 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -1,21 +1,3 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base include ProtectedBranchAccess - - validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, - Gitlab::Access::DEVELOPER, - Gitlab::Access::NO_ACCESS] } - - def self.human_access_levels - { - Gitlab::Access::MASTER => "Masters", - Gitlab::Access::DEVELOPER => "Developers + Masters", - Gitlab::Access::NO_ACCESS => "No one" - }.with_indifferent_access - end - - def check_access(user) - return false if access_level == Gitlab::Access::NO_ACCESS - - super - end end diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb index 83964095516..f38109c0e52 100644 --- a/app/models/protected_tag.rb +++ b/app/models/protected_tag.rb @@ -2,11 +2,7 @@ class ProtectedTag < ActiveRecord::Base include Gitlab::ShellAdapter include ProtectedRef - has_many :create_access_levels, dependent: :destroy - - validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." } - - accepts_nested_attributes_for :create_access_levels + protected_ref_access_levels :create def self.protected?(project, ref_name) self.matching(ref_name, protected_refs: project.protected_tags).present? diff --git a/app/models/readme_blob.rb b/app/models/readme_blob.rb new file mode 100644 index 00000000000..1863a08f1de --- /dev/null +++ b/app/models/readme_blob.rb @@ -0,0 +1,13 @@ +class ReadmeBlob < SimpleDelegator + attr_reader :repository + + def initialize(blob, repository) + @repository = repository + + super(blob) + end + + def rendered_markup + repository.rendered_readme + end +end diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb index 99812bcde53..964175ddab8 100644 --- a/app/models/redirect_route.rb +++ b/app/models/redirect_route.rb @@ -1,5 +1,5 @@ class RedirectRoute < ActiveRecord::Base - belongs_to :source, polymorphic: true + belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations validates :source, presence: true diff --git a/app/models/repository.rb b/app/models/repository.rb index 00b11ecef9f..00a0b407512 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -30,7 +30,7 @@ class Repository METHOD_CACHES_FOR_FILE_TYPES = { readme: :rendered_readme, changelog: :changelog, - license: %i(license_blob license_key), + license: %i(license_blob license_key license), contributing: :contribution_guide, gitignore: :gitignore, koding: :koding_yml, @@ -42,13 +42,13 @@ class Repository # variable. # # This only works for methods that do not take any arguments. - def self.cache_method(name, fallback: nil) + def self.cache_method(name, fallback: nil, memoize_only: false) original = :"_uncached_#{name}" alias_method(original, name) define_method(name) do - cache_method_output(name, fallback: fallback) { __send__(original) } + cache_method_output(name, fallback: fallback, memoize_only: memoize_only) { __send__(original) } end end @@ -517,8 +517,8 @@ class Repository cache_method :avatar def readme - if head = tree(:head) - head.readme + if readme = tree(:head)&.readme + ReadmeBlob.new(readme, self) end end @@ -549,6 +549,13 @@ class Repository end cache_method :license_key + def license + return unless license_key + + Licensee::License.new(license_key) + end + cache_method :license, memoize_only: true + def gitignore file_on_head(:gitignore) end @@ -642,22 +649,8 @@ class Repository "#{name}-#{highest_branch_id + 1}" end - # Remove archives older than 2 hours def branches_sorted_by(value) - case value - when 'name' - branches.sort_by(&:name) - when 'updated_desc' - branches.sort do |a, b| - commit(b.dereferenced_target).committed_date <=> commit(a.dereferenced_target).committed_date - end - when 'updated_asc' - branches.sort do |a, b| - commit(a.dereferenced_target).committed_date <=> commit(b.dereferenced_target).committed_date - end - else - branches - end + raw_repository.local_branches(sort_by: value) end def tags_sorted_by(value) @@ -833,7 +826,7 @@ class Repository actual_options = options.merge( parents: [our_commit, their_commit], - tree: merge_index.write_tree(rugged), + tree: merge_index.write_tree(rugged) ) commit_id = create_commit(actual_options) @@ -953,6 +946,8 @@ class Repository end def is_ancestor?(ancestor_id, descendant_id) + return false if ancestor_id.nil? || descendant_id.nil? + Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled| if is_enabled raw_repository.is_ancestor?(ancestor_id, descendant_id) @@ -1061,14 +1056,20 @@ class Repository # # key - The name of the key to cache the data in. # fallback - A value to fall back to in the event of a Git error. - def cache_method_output(key, fallback: nil, &block) + def cache_method_output(key, fallback: nil, memoize_only: false, &block) ivar = cache_instance_variable_name(key) if instance_variable_defined?(ivar) instance_variable_get(ivar) else begin - instance_variable_set(ivar, cache.fetch(key, &block)) + value = + if memoize_only + yield + else + cache.fetch(key, &block) + end + instance_variable_set(ivar, value) rescue Rugged::ReferenceError, Gitlab::Git::Repository::NoRepository # if e.g. HEAD or the entire repository doesn't exist we want to # gracefully handle this and not cache anything. @@ -1083,8 +1084,8 @@ class Repository def file_on_head(type) if head = tree(:head) - head.blobs.find do |file| - Gitlab::FileDetector.type_of(file.name) == type + head.blobs.find do |blob| + Gitlab::FileDetector.type_of(blob.path) == type end end end diff --git a/app/models/route.rb b/app/models/route.rb index 12a7fa3d01b..97e8a6ad9e9 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -1,5 +1,5 @@ class Route < ActiveRecord::Base - belongs_to :source, polymorphic: true + belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations validates :source, presence: true @@ -35,7 +35,7 @@ class Route < ActiveRecord::Base old_path = route.path # Callbacks must be run manually - route.update_columns(attributes) + route.update_columns(attributes.merge(updated_at: Time.now)) # We are not calling route.delete_conflicting_redirects here, in hopes # of avoiding deadlocks. The parent (self, in this method) already diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index bfaf0eb2fae..edde7bedbab 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -1,8 +1,8 @@ class SentNotification < ActiveRecord::Base - serialize :position, Gitlab::Diff::Position + serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize belongs_to :project - belongs_to :noteable, polymorphic: true + belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :recipient, class_name: "User" validates :project, :recipient, presence: true @@ -39,7 +39,7 @@ class SentNotification < ActiveRecord::Base noteable_type: noteable.class.name, noteable_id: noteable_id, - commit_id: commit_id, + commit_id: commit_id ) create(attrs) diff --git a/app/models/service.rb b/app/models/service.rb index c71a7d169ec..6a0b0a5c522 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -2,7 +2,7 @@ # and implement a set of methods class Service < ActiveRecord::Base include Sortable - serialize :properties, JSON + serialize :properties, JSON # rubocop:disable Cop/ActiverecordSerialize default_value_for :active, false default_value_for :push_events, true @@ -12,7 +12,7 @@ class Service < ActiveRecord::Base 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 :job_events, true default_value_for :pipeline_events, true default_value_for :wiki_page_events, true @@ -40,7 +40,7 @@ class Service < ActiveRecord::Base 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 :job_hooks, -> { where(job_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 } diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 6c3358685fe..54014df43b0 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -11,6 +11,7 @@ class Snippet < ActiveRecord::Base include Editable cache_markdown_field :title, pipeline: :single_line + cache_markdown_field :description cache_markdown_field :content # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets. diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index dd21ee15c6c..56a115d1db4 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -4,8 +4,7 @@ class SpamLog < ActiveRecord::Base validates :user, presence: true def remove_user(deleted_by:) - user.block - DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true) + user.delete_async(deleted_by: deleted_by, params: { hard_delete: true }) end def text diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 17869c8bac2..2f0c9640744 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -1,7 +1,7 @@ class Subscription < ActiveRecord::Base belongs_to :user belongs_to :project - belongs_to :subscribable, polymorphic: true + belongs_to :subscribable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations validates :user, :subscribable, presence: true diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index b44f4fe000c..414c95f7705 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -2,6 +2,7 @@ class SystemNoteMetadata < ActiveRecord::Base ICON_TYPES = %w[ commit description merge confidential visible label assignee cross_reference title time_tracking branch milestone discussion task moved opened closed merged + outdated ].freeze validates :note, presence: true diff --git a/app/models/todo.rb b/app/models/todo.rb index b011001b235..696d139af74 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -22,7 +22,7 @@ class Todo < ActiveRecord::Base belongs_to :author, class_name: "User" belongs_to :note belongs_to :project - belongs_to :target, polymorphic: true, touch: true + belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user delegate :name, :email, to: :author, prefix: true, allow_nil: true diff --git a/app/models/tree.rb b/app/models/tree.rb index fe148b0ec65..c89b8eca9be 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -40,10 +40,7 @@ class Tree readme_path = path == '/' ? readme_tree.name : File.join(path, readme_tree.name) - git_repo = repository.raw_repository - @readme = Gitlab::Git::Blob.find(git_repo, sha, readme_path) - @readme.load_all_data!(git_repo) - @readme + @readme = repository.blob_at(sha, readme_path) end def trees diff --git a/app/models/upload.rb b/app/models/upload.rb index 13987931b05..f194d7bdb80 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -2,7 +2,7 @@ class Upload < ActiveRecord::Base # Upper limit for foreground checksum processing CHECKSUM_THRESHOLD = 100.megabytes - belongs_to :model, polymorphic: true + belongs_to :model, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations validates :size, presence: true validates :path, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index efd53810ec3..f9f8eb98065 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,6 +5,7 @@ class User < ActiveRecord::Base include Gitlab::ConfigHelper include Gitlab::CurrentSettings + include Avatarable include Referable include Sortable include CaseSensitivity @@ -17,6 +18,7 @@ class User < ActiveRecord::Base add_authentication_token_field :authentication_token add_authentication_token_field :incoming_email_token + add_authentication_token_field :rss_token default_value_for :admin, false default_value_for(:external) { current_application_settings.user_default_external } @@ -38,11 +40,22 @@ class User < ActiveRecord::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 + serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiverecordSerialize devise :lockable, :recoverable, :rememberable, :trackable, :validatable, :omniauthable, :confirmable, :registerable + # Override Devise::Models::Trackable#update_tracked_fields! + # to limit database writes to at most once every hour + def update_tracked_fields!(request) + update_tracked_fields(request) + + lease = Gitlab::ExclusiveLease.new("user_update_tracked_fields:#{id}", timeout: 1.hour.to_i) + return unless lease.try_obtain + + save(validate: false) + end + attr_accessor :force_random_password # Virtual attribute for authenticating by either username or email @@ -53,7 +66,7 @@ class User < ActiveRecord::Base # # Namespace for personal projects - has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id + has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id, autosave: true # Profile has_many :keys, -> do @@ -88,6 +101,7 @@ class User < ActiveRecord::Base has_many :snippets, dependent: :destroy, foreign_key: :author_id has_many :notes, dependent: :destroy, foreign_key: :author_id + has_many :issues, dependent: :destroy, foreign_key: :author_id has_many :merge_requests, dependent: :destroy, foreign_key: :author_id has_many :events, dependent: :destroy, foreign_key: :author_id has_many :subscriptions, dependent: :destroy @@ -107,11 +121,6 @@ class User < ActiveRecord::Base has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" - # Issues that a user owns are expected to be moved to the "ghost" user before - # the user is destroyed. If the user owns any issues during deletion, this - # should be treated as an exceptional condition. - has_many :issues, dependent: :restrict_with_exception, foreign_key: :author_id - # # Validations # @@ -157,8 +166,13 @@ class User < ActiveRecord::Base enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos] # User's Project preference - # Note: When adding an option, it MUST go on the end of the array. - enum project_view: [:readme, :activity, :files] + # + # Note: When adding an option, it MUST go on the end of the hash with a + # number higher than the current max. We cannot move options and/or change + # their numbers. + # + # We skip 0 because this was used by an option that has since been removed. + enum project_view: { activity: 1, files: 2 } alias_attribute :private_token, :authentication_token @@ -351,8 +365,9 @@ class User < ActiveRecord::Base # Pattern used to extract `@user` user references from text def reference_pattern %r{ + (?<!\w) #{Regexp.escape(reference_prefix)} - (?<user>#{Gitlab::Regex::FULL_NAMESPACE_REGEX_STR}) + (?<user>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX}) }x end @@ -539,12 +554,6 @@ class User < ActiveRecord::Base authorized_projects(Gitlab::Access::REPORTER).where(id: projects) end - def viewable_starred_projects - starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (?)", - [Project::PUBLIC, Project::INTERNAL], - authorized_projects.select(:project_id)) - end - def owned_projects @owned_projects ||= Project.where('namespace_id IN (?) OR namespace_id = ?', @@ -765,12 +774,10 @@ class User < ActiveRecord::Base email.start_with?('temp-email-for-oauth') end - def avatar_url(size = nil, scale = 2) - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - else - GravatarService.new.execute(email, size, scale) - end + def avatar_url(size: nil, scale: 2, **args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) || GravatarService.new.execute(email, size, scale, username: username) end def all_emails @@ -802,6 +809,11 @@ class User < ActiveRecord::Base system_hook_service.execute_hooks_for(self, :destroy) end + def delete_async(deleted_by:, params: {}) + block if params[:hard_delete] + DeleteUserWorker.perform_async(deleted_by.id, id, params) + end + def notification_service NotificationService.new end @@ -895,13 +907,13 @@ class User < ActiveRecord::Base end def assigned_open_merge_requests_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force) do + Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: 20.minutes) do MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened').execute.count end end def assigned_open_issues_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force) do + Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: 20.minutes) do IssuesFinder.new(self, assignee_id: self.id, state: 'opened').execute.count end end @@ -912,10 +924,25 @@ class User < ActiveRecord::Base end def invalidate_cache_counts +<<<<<<< HEAD Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count']) Rails.cache.delete(['users', id, 'assigned_open_issues_count']) end +======= + invalidate_issue_cache_counts + invalidate_merge_request_cache_counts + end + + def invalidate_issue_cache_counts + Rails.cache.delete(['users', id, 'assigned_open_issues_count']) + end + + def invalidate_merge_request_cache_counts + Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count']) + end + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 def todos_done_count(force: false) Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do TodosFinder.new(self, state: :done).execute.count @@ -973,6 +1000,13 @@ class User < ActiveRecord::Base save end + # each existing user needs to have an `rss_token`. + # we do this on read since migrating all existing users is not a feasible + # solution. + def rss_token + ensure_rss_token! + end + protected # override, from Devise::Validatable diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb index 0949c6ef083..2d05fdd3e54 100644 --- a/app/models/user_agent_detail.rb +++ b/app/models/user_agent_detail.rb @@ -1,5 +1,5 @@ class UserAgentDetail < ActiveRecord::Base - belongs_to :subject, polymorphic: true + belongs_to :subject, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index d4af4490608..2d7405dc240 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -23,7 +23,7 @@ module Ci !::Gitlab::UserAccess .new(user, project: build.project) - .can_push_to_branch?(build.ref) + .can_merge_to_branch?(build.ref) end end end diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb new file mode 100644 index 00000000000..ebab213e6be --- /dev/null +++ b/app/policies/deploy_key_policy.rb @@ -0,0 +1,11 @@ +class DeployKeyPolicy < BasePolicy + def rules + return unless @user + + can! :update_deploy_key if @user.admin? + + if @subject.private? && @user.project_deploy_keys.exists?(id: @subject.id) + can! :update_deploy_key + end + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 87398303c68..fb07298c6c2 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -4,22 +4,25 @@ class GroupPolicy < BasePolicy return unless @user globally_viewable = @subject.public? || (@subject.internal? && !@user.external?) - member = @subject.users_with_parents.include?(@user) - owner = @user.admin? || @subject.has_owner?(@user) - master = owner || @subject.has_master?(@user) + access_level = @subject.max_member_access_for_user(@user) + owner = access_level >= GroupMember::OWNER + master = access_level >= GroupMember::MASTER + reporter = access_level >= GroupMember::REPORTER can_read = false can_read ||= globally_viewable - can_read ||= member - can_read ||= @user.admin? + can_read ||= access_level >= GroupMember::GUEST can_read ||= GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any? can! :read_group if can_read + if reporter + can! :admin_label + end + # Only group masters and group owners can create new projects if master can! :create_projects can! :admin_milestones - can! :admin_label end # Only group owner and administrators can admin group @@ -31,7 +34,7 @@ class GroupPolicy < BasePolicy can! :create_subgroup if @user.can_create_group end - if globally_viewable && @subject.request_access_enabled && !member + if globally_viewable && @subject.request_access_enabled && access_level == GroupMember::NO_ACCESS can! :request_access end end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 8f25ac30a22..3959b895f44 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -98,7 +98,7 @@ class ProjectPolicy < BasePolicy end def master_access! - can! :push_code_to_protected_branches + can! :delete_protected_branch can! :update_project_snippet can! :update_environment can! :update_deployment @@ -173,7 +173,7 @@ class ProjectPolicy < BasePolicy def archived_access! cannot! :create_merge_request cannot! :push_code - cannot! :push_code_to_protected_branches + cannot! :delete_protected_branch cannot! :update_merge_request cannot! :admin_merge_request end @@ -211,7 +211,7 @@ class ProjectPolicy < BasePolicy unless repository_enabled cannot! :push_code - cannot! :push_code_to_protected_branches + cannot! :delete_protected_branch cannot! :download_code cannot! :fork_project cannot! :read_commit_status diff --git a/app/presenters/conversational_development_index/metric_presenter.rb b/app/presenters/conversational_development_index/metric_presenter.rb new file mode 100644 index 00000000000..bb65ba2646b --- /dev/null +++ b/app/presenters/conversational_development_index/metric_presenter.rb @@ -0,0 +1,144 @@ +module ConversationalDevelopmentIndex + class MetricPresenter < Gitlab::View::Presenter::Simple + def cards + [ + Card.new( + metric: subject, + title: 'Issues', + description: 'created per active user', + feature: 'issues', + blog: 'https://www2.deloitte.com/content/dam/Deloitte/se/Documents/technology-media-telecommunications/deloitte-digital-collaboration.pdf' + ), + Card.new( + metric: subject, + title: 'Comments', + description: 'created per active user', + feature: 'notes', + blog: 'http://conversationaldevelopment.com/why/' + ), + Card.new( + metric: subject, + title: 'Milestones', + description: 'created per active user', + feature: 'milestones', + blog: 'http://conversationaldevelopment.com/shorten-cycle/', + docs: help_page_path('user/project/milestones/index') + ), + Card.new( + metric: subject, + title: 'Boards', + description: 'created per active user', + feature: 'boards', + blog: 'http://jpattonassociates.com/user-story-mapping/', + docs: help_page_path('user/project/issue_board') + ), + Card.new( + metric: subject, + title: 'Merge Requests', + description: 'per active user', + feature: 'merge_requests', + blog: 'https://8thlight.com/blog/uncle-bob/2013/02/01/The-Humble-Craftsman.html', + docs: help_page_path('user/project/merge_requests/index') + ), + Card.new( + metric: subject, + title: 'Pipelines', + description: 'created per active user', + feature: 'ci_pipelines', + blog: 'https://martinfowler.com/bliki/ContinuousDelivery.html', + docs: help_page_path('ci/README') + ), + Card.new( + metric: subject, + title: 'Environments', + description: 'created per active user', + feature: 'environments', + blog: 'https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/', + docs: help_page_path('ci/environments') + ), + Card.new( + metric: subject, + title: 'Deployments', + description: 'created per active user', + feature: 'deployments', + blog: 'https://puppet.com/blog/continuous-delivery-vs-continuous-deployment-what-s-diff' + ), + Card.new( + metric: subject, + title: 'Monitoring', + description: 'fraction of all projects', + feature: 'projects_prometheus_active', + blog: 'https://prometheus.io/docs/introduction/overview/', + docs: help_page_path('user/project/integrations/prometheus') + ), + Card.new( + metric: subject, + title: 'Service Desk', + description: 'issues created per active user', + feature: 'service_desk_issues', + blog: 'http://blogs.forrester.com/kate_leggett/17-01-30-top_trends_for_customer_service_in_2017_operations_become_smarter_and_more_strategic', + docs: 'https://docs.gitlab.com/ee/user/project/service_desk.html' + ) + ] + end + + def idea_to_production_steps + [ + IdeaToProductionStep.new( + metric: subject, + title: 'Idea', + features: %w(issues) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Issue', + features: %w(issues notes) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Plan', + features: %w(milestones boards) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Code', + features: %w(merge_requests) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Commit', + features: %w(merge_requests) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Test', + features: %w(ci_pipelines) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Review', + features: %w(ci_pipelines environments) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Staging', + features: %w(environments deployments) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Production', + features: %w(deployments) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Feedback', + features: %w(projects_prometheus_active service_desk_issues) + ) + ] + end + + def average_percentage_score + cards.sum(&:percentage_score) / cards.size.to_f + end + end +end diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 070b0c35e36..229311eb6ee 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -11,7 +11,7 @@ module Projects end def enabled_keys - @enabled_keys ||= project.deploy_keys + @enabled_keys ||= project.deploy_keys.includes(:projects) end def any_keys_enabled? @@ -23,11 +23,7 @@ module Projects end def available_project_keys - @available_project_keys ||= current_user.project_deploy_keys - enabled_keys - end - - def any_available_project_keys_enabled? - available_project_keys.any? + @available_project_keys ||= current_user.project_deploy_keys.includes(:projects) - enabled_keys end def key_available?(deploy_key) @@ -37,17 +33,13 @@ module Projects def available_public_keys return @available_public_keys if defined?(@available_public_keys) - @available_public_keys ||= DeployKey.are_public - enabled_keys + @available_public_keys ||= DeployKey.are_public.includes(:projects) - enabled_keys # Public keys that are already used by another accessible project are already # in @available_project_keys. @available_public_keys -= available_project_keys end - def any_available_public_keys_enabled? - available_public_keys.any? - end - def as_json serializer = DeployKeySerializer.new opts = { user: current_user } diff --git a/app/serializers/analytics_build_entity.rb b/app/serializers/analytics_build_entity.rb index a0db5b8f0f4..ad7ad020b03 100644 --- a/app/serializers/analytics_build_entity.rb +++ b/app/serializers/analytics_build_entity.rb @@ -25,7 +25,7 @@ class AnalyticsBuildEntity < Grape::Entity end expose :url do |build| - url_to(:namespace_project_build, build) + url_to(:namespace_project_job, build) end expose :commit_url do |build| diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 5e99204c658..301b718d060 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -6,7 +6,7 @@ class BuildActionEntity < Grape::Entity end expose :path do |build| - play_namespace_project_build_path( + play_namespace_project_job_path( build.project.namespace, build.project, build) diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb index 8b643d8e783..cb55c98f7c6 100644 --- a/app/serializers/build_artifact_entity.rb +++ b/app/serializers/build_artifact_entity.rb @@ -1,14 +1,39 @@ class BuildArtifactEntity < Grape::Entity include RequestAwareEntity - expose :name do |build| - build.name + expose :name do |job| + job.name end - expose :path do |build| - download_namespace_project_build_artifacts_path( - build.project.namespace, - build.project, - build) + expose :artifacts_expired?, as: :expired + expose :artifacts_expire_at, as: :expire_at + + expose :path do |job| + download_namespace_project_job_artifacts_path( + project.namespace, + project, + job) + end + + expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job| + keep_namespace_project_job_artifacts_path( + project.namespace, + project, + job) + end + + expose :browse_path do |job| + browse_namespace_project_job_artifacts_path( + project.namespace, + project, + job) + end + + private + + alias_method :job, :object + + def project + job.project end end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb new file mode 100644 index 00000000000..0063920e603 --- /dev/null +++ b/app/serializers/build_details_entity.rb @@ -0,0 +1,50 @@ +class BuildDetailsEntity < BuildEntity + expose :coverage, :erased_at, :duration + expose :tag_list, as: :tags + + expose :user, using: UserEntity + + expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity + expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :update_build, project) } do |build| + erase_namespace_project_job_path(project.namespace, project, build) + end + + expose :artifacts, using: BuildArtifactEntity + expose :runner, using: RunnerEntity + expose :pipeline, using: PipelineEntity + + expose :merge_request, if: -> (*) { can?(current_user, :read_merge_request, build.merge_request) } do + expose :iid do |build| + build.merge_request.iid + end + + expose :path do |build| + namespace_project_merge_request_path(project.namespace, project, build.merge_request) + end + end + + expose :new_issue_path, if: -> (*) { can?(request.current_user, :create_issue, project) && build.failed? } do |build| + new_namespace_project_issue_path(project.namespace, project, issue: build_failed_issue_options) + end + + expose :raw_path do |build| + raw_namespace_project_build_path(project.namespace, project, build) + end + + private + + def build_failed_issue_options + { + title: "Build Failed ##{build.id}", + description: namespace_project_job_url(project.namespace, project, build) + } + end + + def current_user + request.current_user + end + + def project + build.project + end +end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index e2276808b90..c01efa9dd5c 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -5,15 +5,15 @@ class BuildEntity < Grape::Entity expose :name expose :build_path do |build| - path_to(:namespace_project_build, build) + path_to(:namespace_project_job, build) end - expose :retry_path do |build| - path_to(:retry_namespace_project_build, build) + expose :retry_path, if: -> (*) { build&.retryable? } do |build| + path_to(:retry_namespace_project_job, build) end expose :play_path, if: -> (*) { playable? } do |build| - path_to(:play_namespace_project_build, build) + path_to(:play_namespace_project_job, build) end expose :playable?, as: :playable diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb index d75a83d0fa5..068013c8829 100644 --- a/app/serializers/deploy_key_entity.rb +++ b/app/serializers/deploy_key_entity.rb @@ -11,4 +11,11 @@ class DeployKeyEntity < Grape::Entity expose :projects, using: ProjectEntity do |deploy_key| deploy_key.projects.select { |project| options[:user].can?(:read_project, project) } end + expose :can_edit + + private + + def can_edit + options[:user].can?(:update_deploy_key, object) + end end diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb index 9607ad55a8b..71d9a65fb58 100644 --- a/app/serializers/entity_date_helper.rb +++ b/app/serializers/entity_date_helper.rb @@ -4,7 +4,7 @@ module EntityDateHelper def interval_in_words(diff) return 'Not started' unless diff - "#{distance_of_time_in_words(Time.now, diff)} ago" + distance_of_time_in_words(Time.now, diff, scope: 'datetime.time_ago_in_words') end # Converts seconds into a hash such as: diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index bc4f68710b2..35df95549b7 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -1,4 +1,6 @@ class IssueEntity < IssuableEntity + include RequestAwareEntity + expose :branch_name expose :confidential expose :assignees, using: API::Entities::UserBasic @@ -7,4 +9,8 @@ class IssueEntity < IssuableEntity expose :project_id expose :milestone, using: API::Entities::Milestone expose :labels, using: LabelEntity + + expose :web_url do |issue| + namespace_project_issue_path(issue.project.namespace, issue.project, issue) + end end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 26fea59cda8..e17ccef21c2 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -29,7 +29,11 @@ class MergeRequestEntity < IssuableEntity expose :merge_commit_sha expose :merge_commit_message +<<<<<<< HEAD expose :head_pipeline, with: PipelineEntity, as: :pipeline +======= + expose :head_pipeline, with: PipelineDetailsEntity, as: :pipeline +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 # Booleans expose :work_in_progress?, as: :work_in_progress @@ -39,6 +43,10 @@ class MergeRequestEntity < IssuableEntity expose :commits_count expose :cannot_be_merged?, as: :has_conflicts expose :can_be_merged?, as: :can_be_merged +<<<<<<< HEAD +======= + expose :remove_source_branch?, as: :remove_source_branch +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expose :project_archived do |merge_request| merge_request.project.archived? @@ -96,6 +104,17 @@ class MergeRequestEntity < IssuableEntity presenter(merge_request).target_branch_commits_path end +<<<<<<< HEAD +======= + expose :new_blob_path do |merge_request| + if can?(current_user, :push_code, merge_request.project) + namespace_project_new_blob_path(merge_request.project.namespace, + merge_request.project, + merge_request.source_branch) + end + end + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expose :conflict_resolution_path do |merge_request| presenter(merge_request).conflict_resolution_path end diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb new file mode 100644 index 00000000000..130968a44c1 --- /dev/null +++ b/app/serializers/pipeline_details_entity.rb @@ -0,0 +1,7 @@ +class PipelineDetailsEntity < PipelineEntity + expose :details do + expose :legacy_stages, as: :stages, using: StageEntity + expose :artifacts, using: BuildArtifactEntity + expose :manual_actions, using: BuildActionEntity + end +end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 51ad0a3f8ba..56a418ea726 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -5,6 +5,12 @@ class PipelineEntity < Grape::Entity expose :user, using: UserEntity expose :active?, as: :active expose :coverage +<<<<<<< HEAD +======= + expose :source + + expose :created_at, :updated_at +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expose :path do |pipeline| namespace_project_pipeline_path( @@ -13,24 +19,20 @@ class PipelineEntity < Grape::Entity pipeline) end - expose :details do - expose :detailed_status, as: :status, with: StatusEntity - expose :duration - expose :finished_at - expose :stages, using: StageEntity - expose :artifacts, using: BuildArtifactEntity - expose :manual_actions, using: BuildActionEntity - end - expose :flags do expose :latest?, as: :latest - expose :triggered?, as: :triggered expose :stuck?, as: :stuck expose :has_yaml_errors?, as: :yaml_errors expose :can_retry?, as: :retryable expose :can_cancel?, as: :cancelable end + expose :details do + expose :detailed_status, as: :status, with: StatusEntity + expose :duration + expose :finished_at + end + expose :ref do expose :name do |pipeline| pipeline.ref @@ -38,10 +40,7 @@ class PipelineEntity < Grape::Entity expose :path do |pipeline| if pipeline.ref - namespace_project_tree_path( - pipeline.project.namespace, - pipeline.project, - id: pipeline.ref) + project_ref_path(pipeline.project, pipeline.ref) end end @@ -50,7 +49,6 @@ class PipelineEntity < Grape::Entity end expose :commit, using: CommitEntity - expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? } expose :retry_path, if: -> (*) { can_retry? } do |pipeline| retry_namespace_project_pipeline_path(pipeline.project.namespace, @@ -64,7 +62,7 @@ class PipelineEntity < Grape::Entity pipeline.id) end - expose :created_at, :updated_at + expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? } private diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index e37af63774c..661bf17983c 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -1,7 +1,7 @@ class PipelineSerializer < BaseSerializer InvalidResourceError = Class.new(StandardError) - entity PipelineEntity + entity PipelineDetailsEntity def with_pagination(request, response) tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) } @@ -13,14 +13,15 @@ class PipelineSerializer < BaseSerializer def represent(resource, opts = {}) if resource.is_a?(ActiveRecord::Relation) + resource = resource.preload([ :retryable_builds, :cancelable_statuses, :trigger_requests, :project, - { pending_builds: :project }, - { manual_actions: :project }, - { artifacts: :project } + :manual_actions, + :artifacts, + { pending_builds: :project } ]) end diff --git a/app/serializers/request_aware_entity.rb b/app/serializers/request_aware_entity.rb index 3039014aaaa..d53fcfb8c1b 100644 --- a/app/serializers/request_aware_entity.rb +++ b/app/serializers/request_aware_entity.rb @@ -3,6 +3,7 @@ module RequestAwareEntity included do include Gitlab::Routing + include GitlabRoutingHelper include Gitlab::Allowable end diff --git a/app/serializers/runner_entity.rb b/app/serializers/runner_entity.rb new file mode 100644 index 00000000000..ed7dacc2dbd --- /dev/null +++ b/app/serializers/runner_entity.rb @@ -0,0 +1,18 @@ +class RunnerEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :description + + expose :edit_path, + if: -> (*) { can?(request.current_user, :admin_build, project) && runner.specific? } do |runner| + edit_namespace_project_runner_path(project.namespace, project, runner) + end + + private + + alias_method :runner, :object + + def project + request.project + end +end diff --git a/app/serializers/user_entity.rb b/app/serializers/user_entity.rb index 43754ea94f7..876512b12dc 100644 --- a/app/serializers/user_entity.rb +++ b/app/serializers/user_entity.rb @@ -1,2 +1,7 @@ class UserEntity < API::Entities::UserBasic + include RequestAwareEntity + + expose :path do |user| + user_path(user) + end end diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb index 76b9f1feda7..8e11a2a36a7 100644 --- a/app/services/akismet_service.rb +++ b/app/services/akismet_service.rb @@ -16,7 +16,7 @@ class AkismetService created_at: DateTime.now, author: owner.name, author_email: owner.email, - referrer: options[:referrer], + referrer: options[:referrer] } begin diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index 8a000585e89..5ad9a50687c 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -8,7 +8,7 @@ class AuditEventService with: @details[:with], target_id: @author.id, target_type: 'User', - target_details: @author.name, + target_details: @author.name } self diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb index fd9ff115eab..68f6a8619e5 100644 --- a/app/services/boards/create_service.rb +++ b/app/services/boards/create_service.rb @@ -12,6 +12,7 @@ module Boards def create_board! board = project.boards.create + board.lists.create(list_type: :backlog) board.lists.create(list_type: :closed) board diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 533e6787855..418fa9afd6e 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -3,7 +3,7 @@ module Boards class ListService < BaseService def execute issues = IssuesFinder.new(current_user, filter_params).execute - issues = without_board_labels(issues) unless list + issues = without_board_labels(issues) unless movable_list? issues = with_list_label(issues) if movable_list? issues.order_by_position_and_priority end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index e73b1a4361a..ecabb2a48e4 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -38,7 +38,7 @@ module Boards attrs.merge!( add_label_ids: add_label_ids, remove_label_ids: remove_label_ids, - state_event: issue_state, + state_event: issue_state ) end diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb index c579ed4c869..df2a01a69e5 100644 --- a/app/services/boards/lists/list_service.rb +++ b/app/services/boards/lists/list_service.rb @@ -2,6 +2,8 @@ module Boards module Lists class ListService < BaseService def execute(board) + board.lists.create(list_type: :backlog) unless board.lists.backlog.exists? + board.lists end end diff --git a/app/services/ci/create_pipeline_builds_service.rb b/app/services/ci/create_pipeline_builds_service.rb deleted file mode 100644 index 70fb2c5e38f..00000000000 --- a/app/services/ci/create_pipeline_builds_service.rb +++ /dev/null @@ -1,51 +0,0 @@ -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 - - delegate :project, to: :pipeline - - private - - def create_build(build_attributes) - build_attributes = build_attributes.merge( - pipeline: pipeline, - project: project, - ref: pipeline.ref, - tag: pipeline.tag, - user: current_user, - trigger_request: trigger_request - ) - build = pipeline.builds.create(build_attributes) - - # Create the environment before the build starts. This sets its slug and - # makes it available as an environment variable - project.environments.find_or_create_by(name: build.expanded_environment_name) if - build.has_environment? - - build - 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 ccdda08d885..bffec216819 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -2,8 +2,9 @@ module Ci class CreatePipelineService < BaseService attr_reader :pipeline - def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil) + def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil) @pipeline = Ci::Pipeline.new( + source: source, project: project, ref: ref, sha: sha, @@ -42,14 +43,14 @@ module Ci return pipeline end - unless pipeline.config_builds_attributes.present? - return error('No builds for this pipeline.') + unless pipeline.has_stage_seeds? + return error('No stages / jobs for this pipeline.') end Ci::Pipeline.transaction do - pipeline.save + update_merge_requests_head_pipeline if pipeline.save - Ci::CreatePipelineBuildsService + Ci::CreatePipelineStagesService .new(project, current_user) .execute(pipeline) end @@ -61,6 +62,13 @@ module Ci private + def update_merge_requests_head_pipeline + return unless pipeline.latest? + + MergeRequest.where(source_project: @pipeline.project, source_branch: @pipeline.ref). + update_all(head_pipeline_id: @pipeline.id) + end + def skip_ci? return false unless pipeline.git_commit_message pipeline.git_commit_message =~ /\[(ci[ _-]skip|skip[ _-]ci)\]/i diff --git a/app/services/ci/create_pipeline_stages_service.rb b/app/services/ci/create_pipeline_stages_service.rb new file mode 100644 index 00000000000..f2c175adee6 --- /dev/null +++ b/app/services/ci/create_pipeline_stages_service.rb @@ -0,0 +1,20 @@ +module Ci + class CreatePipelineStagesService < BaseService + def execute(pipeline) + pipeline.stage_seeds.each do |seed| + seed.user = current_user + + seed.create! do |build| + ## + # Create the environment before the build starts. This sets its slug and + # makes it available as an environment variable + # + if build.has_environment? + environment_name = build.expanded_environment_name + project.environments.find_or_create_by(name: environment_name) + end + end + end + end + end +end diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index 8362f01ddb8..beb27a5a597 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -4,7 +4,7 @@ module Ci trigger_request = trigger.trigger_requests.create(variables: variables) pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref). - execute(ignore_skip_ci: true, trigger_request: trigger_request) + execute(:trigger, ignore_skip_ci: true, trigger_request: trigger_request) trigger_request if pipeline.persisted? end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index f51e9fd1d54..6372e5755db 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -1,7 +1,7 @@ module Ci class RetryBuildService < ::BaseService CLONE_ACCESSORS = %i[pipeline project ref tag options commands name - allow_failure stage stage_idx trigger_request + allow_failure stage_id stage stage_idx trigger_request yaml_variables when environment coverage_regex description tag_list].freeze diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb index ab4c02a97a0..a5ae4927412 100644 --- a/app/services/compare_service.rb +++ b/app/services/compare_service.rb @@ -17,18 +17,18 @@ class CompareService start_branch_name) do |commit| break unless commit - compare(commit.sha, target_project, target_branch, straight) + compare(commit.sha, target_project, target_branch, straight: straight) end end private - def compare(source_sha, target_project, target_branch, straight) + def compare(source_sha, target_project, target_branch, straight:) raw_compare = Gitlab::Git::Compare.new( target_project.repository.raw_repository, target_branch, source_sha, - straight + straight: straight ) Compare.new(raw_compare, target_project, straight: straight) diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb index 47f9b2c621c..46823418bb0 100644 --- a/app/services/create_deployment_service.rb +++ b/app/services/create_deployment_service.rb @@ -1,71 +1,59 @@ -class CreateDeploymentService < BaseService - def execute(deployable = nil) +class CreateDeploymentService + attr_reader :job + + delegate :expanded_environment_name, + :environment_url, + :project, + to: :job + + def initialize(job) + @job = job + end + + def execute return unless executable? ActiveRecord::Base.transaction do - @deployable = deployable + environment.external_url = environment_url if environment_url + environment.fire_state_event(action) - @environment = environment - @environment.external_url = expanded_url if expanded_url - @environment.fire_state_event(action) + return unless environment.save + return if environment.stopped? - return unless @environment.save - return if @environment.stopped? - - deploy.tap do |deployment| - deployment.update_merge_request_metrics! - end + deploy.tap(&:update_merge_request_metrics!) end end private def executable? - project && name.present? + project && job.environment.present? && environment end def deploy project.deployments.create( - environment: @environment, - ref: params[:ref], - tag: params[:tag], - sha: params[:sha], - user: current_user, - deployable: @deployable, - on_stop: options[:on_stop]) + environment: environment, + ref: job.ref, + tag: job.tag, + sha: job.sha, + user: job.user, + deployable: job, + on_stop: on_stop) end def environment - @environment ||= project.environments.find_or_create_by(name: expanded_name) - 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] + @environment ||= job.persisted_environment end - def options - params[:options] || {} + def environment_options + @environment_options ||= job.options&.dig(:environment) || {} end - def variables - params[:variables] || [] + def on_stop + environment_options[:on_stop] end def action - options[:action] || 'start' + environment_options[:action] || 'start' end end diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 38a113caec7..64b3c0118fb 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -3,22 +3,14 @@ class DeleteBranchService < BaseService repository = project.repository branch = repository.find_branch(branch_name) - unless branch - return error('No such branch', 404) - end - - if branch_name == repository.root_ref - return error('Cannot remove HEAD branch', 405) - end - - if ProtectedBranch.protected?(project, branch_name) - return error('Protected branch cant be removed', 405) - end - unless current_user.can?(:push_code, project) return error('You dont have push access to repo', 405) end + unless branch + return error('No such branch', 404) + end + if repository.rm_branch(current_user, branch_name) success('Branch was removed') else diff --git a/app/services/discussions/update_diff_position_service.rb b/app/services/discussions/update_diff_position_service.rb new file mode 100644 index 00000000000..1ef8d9edbe1 --- /dev/null +++ b/app/services/discussions/update_diff_position_service.rb @@ -0,0 +1,41 @@ +module Discussions + class UpdateDiffPositionService < BaseService + def execute(discussion) + result = tracer.trace(discussion.position) + return unless result + + position = result[:position] + outdated = result[:outdated] + + discussion.notes.each do |note| + if outdated + note.change_position = position + else + note.position = position + note.change_position = nil + end + end + + Note.transaction do + discussion.notes.each do |note| + Gitlab::Timeless.timeless(note, &:save) + end + + if outdated && current_user + SystemNoteService.diff_discussion_outdated(discussion, project, current_user, position) + end + end + end + + private + + def tracer + @tracer ||= Gitlab::Diff::PositionTracer.new( + project: project, + old_diff_refs: params[:old_diff_refs], + new_diff_refs: params[:new_diff_refs], + paths: params[:paths] + ) + end + end +end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 45411c779cc..f080e6326a1 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -67,7 +67,7 @@ class GitPushService < BaseService paths = Set.new @push_commits.each do |commit| - commit.raw_diffs(deltas_only: true).each do |diff| + commit.raw_deltas.each do |diff| paths << diff.new_path end end @@ -85,8 +85,10 @@ class GitPushService < BaseService default = is_default_branch? push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit| - ProcessCommitWorker. - perform_async(project.id, current_user.id, commit.to_hash, default) + if commit.matches_cross_reference_regex? + ProcessCommitWorker. + perform_async(project.id, current_user.id, commit.to_hash, default) + end end end @@ -104,7 +106,7 @@ class GitPushService < BaseService EventCreateService.new.push(@project, current_user, build_push_data) @project.execute_hooks(build_push_data.dup, :push_hooks) @project.execute_services(build_push_data.dup, :push_hooks) - Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute + Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute(:push) if push_remove_branch? AfterBranchDeleteService diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index 96432837481..7c424fba428 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) - Ci::CreatePipelineService.new(project, current_user, @push_data).execute + Ci::CreatePipelineService.new(project, current_user, @push_data).execute(:push) ProjectCacheWorker.perform_async(project.id, [], [:commit_count, :repository_size]) true diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb index 433ecc2df32..e77e08aa380 100644 --- a/app/services/gravatar_service.rb +++ b/app/services/gravatar_service.rb @@ -1,15 +1,20 @@ class GravatarService include Gitlab::CurrentSettings - def execute(email, size = nil, scale = 2) - if current_application_settings.gravatar_enabled? && email.present? - size = 40 if size.nil? || size <= 0 + def execute(email, size = nil, scale = 2, username: nil) + return unless current_application_settings.gravatar_enabled? - sprintf gravatar_url, - hash: Digest::MD5.hexdigest(email.strip.downcase), - size: size * scale, - email: email.strip - end + identifier = email.presence || username.presence + return unless identifier + + hash = Digest::MD5.hexdigest(identifier.strip.downcase) + size = 40 unless size && size > 0 + + sprintf gravatar_url, + hash: hash, + size: size * scale, + email: ERB::Util.url_encode(email&.strip || ''), + username: ERB::Util.url_encode(username&.strip || '') end def gitlab_config diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index dc2ab99b982..b44dedaf7e6 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -148,7 +148,7 @@ class IssuableBaseService < BaseService execute(params[:description], issuable) # Avoid a description already set on an issuable to be overwritten by a nil - params[:description] = description if params.has_key?(:description) + params[:description] = description if params.key?(:description) params.merge!(command_params) end @@ -178,7 +178,11 @@ class IssuableBaseService < BaseService after_create(issuable) issuable.create_cross_references!(current_user) execute_hooks(issuable) +<<<<<<< HEAD issuable.assignees.each(&:invalidate_cache_counts) +======= + invalidate_cache_counts(issuable.assignees, issuable) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end issuable @@ -237,7 +241,11 @@ class IssuableBaseService < BaseService if old_assignees != issuable.assignees assignees = old_assignees + issuable.assignees.to_a +<<<<<<< HEAD assignees.compact.each(&:invalidate_cache_counts) +======= + invalidate_cache_counts(assignees.compact, issuable) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end after_update(issuable) @@ -330,4 +338,10 @@ class IssuableBaseService < BaseService create_labels_note(issuable, old_labels) if issuable.labels != old_labels end + + def invalidate_cache_counts(users, issuable) + users.each do |user| + user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") + end + end end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index f1030912c68..85c616ca576 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -28,6 +28,7 @@ module Issues notification_service.close_issue(issue, current_user) if notifications todo_service.close_issue(issue, current_user) execute_hooks(issue, 'close') + invalidate_cache_counts(issue.assignees, issue) end issue diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index 40fbe354492..80ea6312768 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -8,6 +8,7 @@ module Issues create_note(issue) notification_service.reopen_issue(issue, current_user) execute_hooks(issue, 'reopen') + invalidate_cache_counts(issue.assignees, issue) end issue diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 3a58f6c065d..26906ae7167 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -1,22 +1,38 @@ module Members class CreateService < BaseService + DEFAULT_LIMIT = 100 + def initialize(source, current_user, params = {}) @source = source @current_user = current_user @params = params + @error = nil end def execute - return false if params[:user_ids].blank? + return error('No users specified.') if params[:user_ids].blank? + + user_ids = params[:user_ids].split(',').uniq + + return error("Too many users specified (limit is #{user_limit})") if + user_limit && user_ids.size > user_limit @source.add_users( - params[:user_ids].split(','), + user_ids, params[:access_level], expires_at: params[:expires_at], current_user: current_user ) - true + success + end + + private + + def user_limit + limit = params.fetch(:limit, DEFAULT_LIMIT) + + limit && limit < 0 ? nil : limit end end end diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index f2053bda83a..2ffc989ed71 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -13,6 +13,7 @@ module MergeRequests notification_service.close_mr(merge_request, current_user) todo_service.close_merge_request(merge_request, current_user) execute_hooks(merge_request, 'close') + invalidate_cache_counts(merge_request.assignees, merge_request) end merge_request diff --git a/app/services/merge_requests/conflicts/resolve_service.rb b/app/services/merge_requests/conflicts/resolve_service.rb index d74a82effd6..a288f48d665 100644 --- a/app/services/merge_requests/conflicts/resolve_service.rb +++ b/app/services/merge_requests/conflicts/resolve_service.rb @@ -37,11 +37,21 @@ module MergeRequests private def write_resolved_file_to_index(merge_index, rugged, file, params) +<<<<<<< HEAD new_file = if params[:sections] file.resolve_lines(params[:sections]).map(&:text).join("\n") elsif params[:content] file.resolve_content(params[:content]) end +======= + if params[:sections] + new_file = file.resolve_lines(params[:sections]).map(&:text).join("\n") + + new_file << "\n" if file.our_blob.data.ends_with?("\n") + elsif params[:content] + new_file = file.resolve_content(params[:content]) + end +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 our_path = file.our_path diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index b0ae2dfe4ce..71d37797bb4 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -11,7 +11,9 @@ module MergeRequests merge_request = MergeRequest.new merge_request.source_project = source_project + merge_request.source_branch = params[:source_branch] merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch) + merge_request.head_pipeline = head_pipeline_for(merge_request) create(merge_request) end @@ -22,5 +24,18 @@ module MergeRequests todo_service.new_merge_request(issuable, current_user) issuable.cache_merge_request_closes_issues!(current_user) end + + private + + def head_pipeline_for(merge_request) + return unless merge_request.source_project + + sha = merge_request.source_branch_sha + return unless sha + + pipelines = merge_request.source_project.pipelines.where(ref: merge_request.source_branch, sha: sha) + + pipelines.order(id: :desc).first + end end end diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index e8fb1b59752..f0d998731d7 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -13,6 +13,7 @@ module MergeRequests create_note(merge_request) notification_service.merge_mr(merge_request, current_user) execute_hooks(merge_request, 'merge') + invalidate_cache_counts(merge_request.assignees, merge_request) end private diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 1131d6f4913..81d217929d5 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -66,12 +66,12 @@ module MergeRequests filter_merge_requests(merge_requests).each do |merge_request| if merge_request.source_branch == @branch_name || force_push? - merge_request.reload_diff + merge_request.reload_diff(current_user) else mr_commit_ids = merge_request.commits_sha push_commit_ids = @commits.map(&:id) matches = mr_commit_ids & push_commit_ids - merge_request.reload_diff if matches.any? + merge_request.reload_diff(current_user) if matches.any? end merge_request.mark_as_unchecked diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index fadcce5d9b6..f2fddf7f345 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -8,8 +8,9 @@ module MergeRequests create_note(merge_request) notification_service.reopen_mr(merge_request, current_user) execute_hooks(merge_request, 'reopen') - merge_request.reload_diff + merge_request.reload_diff(current_user) merge_request.mark_as_unchecked + invalidate_cache_counts(merge_request.assignees, merge_request) end merge_request diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb new file mode 100644 index 00000000000..d726db4e99b --- /dev/null +++ b/app/services/metrics_service.rb @@ -0,0 +1,33 @@ +require 'prometheus/client/formats/text' + +class MetricsService + CHECKS = [ + Gitlab::HealthChecks::DbCheck, + Gitlab::HealthChecks::RedisCheck, + Gitlab::HealthChecks::FsShardsCheck + ].freeze + + def prometheus_metrics_text + Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path) + end + + def health_metrics_text + metrics = CHECKS.flat_map(&:metrics) + + formatter.marshal(metrics) + end + + def metrics_text + "#{health_metrics_text}#{prometheus_metrics_text}" + end + + private + + def formatter + @formatter ||= Gitlab::HealthChecks::PrometheusTextFormat.new + end + + def multiprocess_metrics_path + @multiprocess_metrics_path ||= Rails.root.join(ENV['prometheus_multiproc_dir']).freeze + end +end diff --git a/app/services/notes/diff_position_update_service.rb b/app/services/notes/diff_position_update_service.rb deleted file mode 100644 index 0cb731f5bc3..00000000000 --- a/app/services/notes/diff_position_update_service.rb +++ /dev/null @@ -1,30 +0,0 @@ -module Notes - class DiffPositionUpdateService < BaseService - def execute(note) - new_position = tracer.trace(note.position) - - # Don't update the position if the type doesn't match, since that means - # the diff line commented on was changed, and the comment is now outdated - old_position = note.position - if new_position && - new_position != old_position && - new_position.type == old_position.type - - note.position = new_position - end - - note - end - - private - - def tracer - @tracer ||= Gitlab::Diff::PositionTracer.new( - repository: project.repository, - old_diff_refs: params[:old_diff_refs], - new_diff_refs: params[:new_diff_refs], - paths: params[:paths] - ) - end - end -end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index c65c66d7150..646ccbdb2bf 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -298,7 +298,7 @@ class NotificationService recipients ||= NotificationRecipientService.new(pipeline.project).build_pipeline_recipients( pipeline, pipeline.user, - action: pipeline.status, + action: pipeline.status ).map(&:notification_email) if recipients.any? diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 535d93385e6..e874a2d8789 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -48,15 +48,14 @@ module Projects save_project_and_import_data(import_data) - @project.import_start if @project.import? - after_create_actions if @project.persisted? if @project.errors.empty? - @project.add_import_job if @project.import? + @project.import_schedule if @project.import? else fail(error: @project.errors.full_messages.join(', ')) end + @project rescue ActiveRecord::RecordInvalid => e message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} " diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 06d8d143231..e2b2660ea71 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -7,11 +7,9 @@ module Projects DELETED_FLAG = '+deleted'.freeze 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 + 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 def execute @@ -62,7 +60,11 @@ module Projects if gitlab_shell.mv_repository(project.repository_storage_path, path, new_path) log_info("Repository \"#{path}\" moved to \"#{new_path}\"") - GitlabShellWorker.perform_in(5.minutes, :remove_repository, project.repository_storage_path, new_path) + + project.run_after_commit do + # self is now project + GitlabShellWorker.perform_in(5.minutes, :remove_repository, self.repository_storage_path, new_path) + end else false end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index da6e6acd4a7..1c24b27a870 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -12,12 +12,13 @@ module Projects TransferError = Class.new(StandardError) def execute(new_namespace) - if allowed_transfer?(current_user, project, new_namespace) - transfer(project, new_namespace) - else - project.errors.add(:new_namespace, 'is invalid') - false + if new_namespace.blank? + raise TransferError, 'Please select a new namespace for your project.' end + unless allowed_transfer?(current_user, project, new_namespace) + raise TransferError, 'Transfer failed, please contact an admin.' + end + transfer(project, new_namespace) rescue Projects::TransferService::TransferError => ex project.reload project.errors.add(:new_namespace, ex.message) diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index eb4809afa85..cacb74b1205 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -27,7 +27,7 @@ module Projects { domain: domain.domain, certificate: domain.certificate, - key: domain.key, + key: domain.key } end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 22736c71725..1d4d03a8b7d 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -12,7 +12,7 @@ class SearchService @project = if params[:project_id].present? the_project = Project.find_by(id: params[:project_id]) - can?(current_user, :download_code, the_project) ? the_project : nil + can?(current_user, :read_project, the_project) ? the_project : nil else nil end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index a7e13648b54..b6b411d2185 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -92,26 +92,20 @@ module SlashCommands desc 'Assign' explanation do |users| - "Assigns #{users.map(&:to_reference).to_sentence}." if users.any? + "Assigns #{users.first.to_reference}." if users.any? end params '@user' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) end parse_params do |assignee_param| - users = extract_references(assignee_param, :user) - - if users.empty? - users = User.where(username: assignee_param.split(' ').map(&:strip)) - end - - users + extract_users(assignee_param) end command :assign do |users| next if users.empty? if issuable.is_a?(Issue) - @updates[:assignee_ids] = users.map(&:id) + @updates[:assignee_ids] = [users.last.id] else @updates[:assignee_id] = users.last.id end @@ -459,6 +453,18 @@ module SlashCommands end end + def extract_users(params) + return [] if params.nil? + + users = extract_references(params, :user) + + if users.empty? + users = User.where(username: params.split(' ').map(&:strip)) + end + + users + end + def find_labels(labels_param) extract_references(labels_param, :label) | LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb new file mode 100644 index 00000000000..17857ca62f2 --- /dev/null +++ b/app/services/submit_usage_ping_service.rb @@ -0,0 +1,41 @@ +class SubmitUsagePingService + URL = 'https://version.gitlab.com/usage_data'.freeze + + include Gitlab::CurrentSettings + + def execute + return false unless current_application_settings.usage_ping_enabled? + + response = HTTParty.post( + URL, + body: Gitlab::UsageData.to_json(force_refresh: true), + headers: { 'Content-type' => 'application/json' } + ) + + store_metrics(response) + + true + rescue HTTParty::Error => e + Rails.logger.info "Unable to contact GitLab, Inc.: #{e}" + + false + end + + private + + def store_metrics(response) + return unless response['conv_index'].present? + + ConversationalDevelopmentIndex::Metric.create!( + response['conv_index'].slice( + 'leader_issues', 'instance_issues', 'leader_notes', 'instance_notes', + 'leader_milestones', 'instance_milestones', 'leader_boards', 'instance_boards', + 'leader_merge_requests', 'instance_merge_requests', 'leader_ci_pipelines', + 'instance_ci_pipelines', 'leader_environments', 'instance_environments', + 'leader_deployments', 'instance_deployments', 'leader_projects_prometheus_active', + 'instance_projects_prometheus_active', 'leader_service_desk_issues', + 'instance_service_desk_issues' + ) + ) + end +end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index af0ddbe5934..ed476fc9d0c 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -51,7 +51,7 @@ class SystemHooksService path: model.path, group_id: model.id, owner_name: owner.respond_to?(:name) ? owner.name : nil, - owner_email: owner.respond_to?(:email) ? owner.email : nil, + owner_email: owner.respond_to?(:email) ? owner.email : nil ) when GroupMember data.merge!(group_member_data(model)) @@ -113,7 +113,7 @@ class SystemHooksService user_name: model.user.name, user_email: model.user.email, user_id: model.user.id, - group_access: model.human_access, + group_access: model.human_access } end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 0766df50ed2..0837c07e6aa 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -258,7 +258,7 @@ module SystemNoteService create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end - def self.resolve_all_discussions(merge_request, project, author) + def resolve_all_discussions(merge_request, project, author) body = "resolved all discussions" create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion')) @@ -274,6 +274,28 @@ module SystemNoteService note end + def diff_discussion_outdated(discussion, project, author, change_position) + merge_request = discussion.noteable + diff_refs = change_position.diff_refs + version_index = merge_request.merge_request_diffs.viewable.count + + body = "changed this line in" + if version_params = merge_request.version_params_for(diff_refs) + line_code = change_position.line_code(project.repository) + url = url_helpers.diffs_namespace_project_merge_request_url(project.namespace, project, merge_request, version_params.merge(anchor: line_code)) + + body << " [version #{version_index} of the diff](#{url})" + else + body << " version #{version_index} of the diff" + end + + note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) + note = Note.create(note_attributes.merge(system: true)) + note.system_note_metadata = SystemNoteMetadata.new(action: 'outdated') + + note + end + # Called when the title of a Noteable is changed # # noteable - Noteable object that responds to `title` @@ -291,8 +313,8 @@ module SystemNoteService old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs - marked_old_title = Gitlab::Diff::InlineDiffMarker.new(old_title).mark(old_diffs, mode: :deletion, markdown: true) - marked_new_title = Gitlab::Diff::InlineDiffMarker.new(new_title).mark(new_diffs, mode: :addition, markdown: true) + marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs, mode: :deletion) + marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs, mode: :addition) body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**" diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index facf21a7f5c..ab532a1fdcf 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -16,7 +16,7 @@ module Users def record_activity Gitlab::UserActivities.record(@author.id) - Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username}") + Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username})") end end end diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index 9eb6a600f6b..673afb8b5b9 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -6,12 +6,27 @@ module Users @current_user = current_user end + # Synchronously destroys +user+ + # + # The operation will fail if the user is the sole owner of any groups. To + # force the groups to be destroyed, pass `delete_solo_owned_groups: true` in + # +options+. + # + # The user's contributions will be migrated to a global ghost user. To + # force the contributions to be destroyed, pass `hard_delete: true` in + # +options+. + # + # `hard_delete: true` implies `delete_solo_owned_groups: true`. To perform + # a hard deletion without destroying solo-owned groups, pass + # `delete_solo_owned_groups: false, hard_delete: true` in +options+. def execute(user, options = {}) + delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete]) + unless Ability.allowed?(current_user, :destroy_user, user) raise Gitlab::Access::AccessDeniedError, "#{current_user} tried to destroy user #{user}!" end - if !options[:delete_solo_owned_groups] && user.solo_owned_groups.present? + if !delete_solo_owned_groups && user.solo_owned_groups.present? user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user' return user end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb new file mode 100644 index 00000000000..4241b912d5b --- /dev/null +++ b/app/services/web_hook_service.rb @@ -0,0 +1,120 @@ +class WebHookService + class InternalErrorResponse + attr_reader :body, :headers, :code + + def initialize + @headers = HTTParty::Response::Headers.new({}) + @body = '' + @code = 'internal error' + end + end + + include HTTParty + + # HTTParty timeout + default_timeout Gitlab.config.gitlab.webhook_timeout + + attr_accessor :hook, :data, :hook_name + + def initialize(hook, data, hook_name) + @hook = hook + @data = data + @hook_name = hook_name + end + + def execute + start_time = Time.now + + response = if parsed_url.userinfo.blank? + make_request(hook.url) + else + make_request_with_auth + end + + log_execution( + trigger: hook_name, + url: hook.url, + request_data: data, + response: response, + execution_duration: Time.now - start_time + ) + + [response.code, response.to_s] + rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout => e + log_execution( + trigger: hook_name, + url: hook.url, + request_data: data, + response: InternalErrorResponse.new, + execution_duration: Time.now - start_time, + error_message: e.to_s + ) + + Rails.logger.error("WebHook Error => #{e}") + + [nil, e.to_s] + end + + def async_execute + Sidekiq::Client.enqueue(WebHookWorker, hook.id, data, hook_name) + end + + private + + def parsed_url + @parsed_url ||= URI.parse(hook.url) + end + + def make_request(url, basic_auth = false) + self.class.post(url, + body: data.to_json, + headers: build_headers(hook_name), + verify: hook.enable_ssl_verification, + basic_auth: basic_auth) + end + + def make_request_with_auth + post_url = hook.url.gsub("#{parsed_url.userinfo}@", '') + basic_auth = { + username: CGI.unescape(parsed_url.user), + password: CGI.unescape(parsed_url.password) + } + make_request(post_url, basic_auth) + end + + def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil) + # logging for ServiceHook's is not available + return if hook.is_a?(ServiceHook) + + WebHookLog.create( + web_hook: hook, + trigger: trigger, + url: url, + execution_duration: execution_duration, + request_headers: build_headers(hook_name), + request_data: request_data, + response_headers: format_response_headers(response), + response_body: response.body, + response_status: response.code, + internal_error_message: error_message + ) + end + + def build_headers(hook_name) + @headers ||= begin + { + 'Content-Type' => 'application/json', + 'X-Gitlab-Event' => hook_name.singularize.titleize + }.tap do |hash| + hash['X-Gitlab-Token'] = hook.token if hook.token.present? + end + end + end + + # Make response headers more stylish + # Net::HTTPHeader has downcased hash with arrays: { 'content-type' => ['text/html; charset=utf-8'] } + # This method format response to capitalized hash with strings: { 'Content-Type' => 'text/html; charset=utf-8' } + def format_response_headers(response) + response.headers.each_capitalized.to_h + end +end diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb index 3e36ec91205..3bc0408f557 100644 --- a/app/uploaders/artifact_uploader.rb +++ b/app/uploaders/artifact_uploader.rb @@ -1,33 +1,35 @@ class ArtifactUploader < GitlabUploader storage :file - attr_accessor :build, :field + attr_reader :job, :field - def self.artifacts_path + def self.local_artifacts_store Gitlab.config.artifacts.path end def self.artifacts_upload_path - File.join(self.artifacts_path, 'tmp/uploads/') + File.join(self.local_artifacts_store, 'tmp/uploads/') end - def self.artifacts_cache_path - File.join(self.artifacts_path, 'tmp/cache/') - end - - def initialize(build, field) - @build, @field = build, field + def initialize(job, field) + @job, @field = job, field end def store_dir - File.join(self.class.artifacts_path, @build.artifacts_path) + default_local_path end def cache_dir - File.join(self.class.artifacts_cache_path, @build.artifacts_path) + File.join(self.class.local_artifacts_store, 'tmp/cache') + end + + private + + def default_local_path + File.join(self.class.local_artifacts_store, default_path) end - def filename - file.try(:filename) + def default_path + File.join(job.created_at.utc.strftime('%Y_%m'), job.project_id.to_s, job.id.to_s) end end diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb new file mode 100644 index 00000000000..00c2888d224 --- /dev/null +++ b/app/uploaders/file_mover.rb @@ -0,0 +1,63 @@ +class FileMover + attr_reader :secret, :file_name, :model, :update_field + + def initialize(file_path, model, update_field = :description) + @secret = File.split(File.dirname(file_path)).last + @file_name = File.basename(file_path) + @model = model + @update_field = update_field + end + + def execute + move + uploader.record_upload if update_markdown + end + + private + + def move + FileUtils.mkdir_p(File.dirname(file_path)) + FileUtils.move(temp_file_path, file_path) + end + + def update_markdown + updated_text = model.read_attribute(update_field).gsub(temp_file_uploader.to_markdown, uploader.to_markdown) + model.update_attribute(update_field, updated_text) + + true + rescue + revert + + false + end + + def temp_file_path + return @temp_file_path if @temp_file_path + + temp_file_uploader.retrieve_from_store!(file_name) + + @temp_file_path = temp_file_uploader.file.path + end + + def file_path + return @file_path if @file_path + + uploader.retrieve_from_store!(file_name) + + @file_path = uploader.file.path + end + + def uploader + @uploader ||= PersonalFileUploader.new(model, secret) + end + + def temp_file_uploader + @temp_file_uploader ||= PersonalFileUploader.new(nil, secret) + end + + def revert + Rails.logger.warn("Markdown not updated, file move reverted for #{model}") + + FileUtils.move(file_path, temp_file_path) + end +end diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index 449850bf0d5..42fd2fc1613 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -19,8 +19,17 @@ class GitlabUploader < CarrierWave::Uploader::Base File.join(root_dir, 'system') end +<<<<<<< HEAD def self.file_storage? self.storage == CarrierWave::Storage::File +======= + def file_storage? + storage.is_a?(CarrierWave::Storage::File) + end + + def file_cache_storage? + cache_storage.is_a?(CarrierWave::Storage::File) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end delegate :base_dir, :file_storage?, to: :class diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb index 95a891111e1..02589959c2f 100644 --- a/app/uploaders/lfs_object_uploader.rb +++ b/app/uploaders/lfs_object_uploader.rb @@ -12,4 +12,20 @@ class LfsObjectUploader < GitlabUploader def filename model.oid[4..-1] end + + def work_dir + File.join(Gitlab.config.lfs.storage_path, 'tmp', 'work') + end + + private + + # To prevent LFS files from moving across filesystems, override the default + # implementation: + # http://github.com/carrierwaveuploader/carrierwave/blob/v1.0.0/lib/carrierwave/uploader/cache.rb#L181-L183 + def workfile_path(for_file = original_filename) + # To be safe, keep this directory outside of the the cache directory + # because calling CarrierWave.clean_cache_files! will remove any files in + # the cache directory. + File.join(work_dir, @cache_id, version_name.to_s, for_file) + end end diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb index 969b0a20d38..7f857765fbf 100644 --- a/app/uploaders/personal_file_uploader.rb +++ b/app/uploaders/personal_file_uploader.rb @@ -10,6 +10,10 @@ class PersonalFileUploader < FileUploader end def self.model_path(model) - File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s) + if model + File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s) + else + File.join("/#{base_dir}", 'temp') + end end end diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 4c127f29250..feb4f04d7b7 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -6,8 +6,6 @@ module RecordsUploads before :remove, :destroy_upload end - private - # After storing an attachment, create a corresponding Upload record # # NOTE: We're ignoring the argument passed to this callback because we want @@ -15,13 +13,16 @@ module RecordsUploads # `Tempfile` object the callback gets. # # Called `after :store` - def record_upload(_tempfile) + def record_upload(_tempfile = nil) + return unless model return unless file_storage? return unless file.exists? Upload.record(self) end + private + # Before removing an attachment, destroy any Upload records at the same path # # Called `before :remove` diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb index 8d4d7180baf..7f42d728da1 100644 --- a/app/validators/dynamic_path_validator.rb +++ b/app/validators/dynamic_path_validator.rb @@ -3,9 +3,10 @@ # Custom validator for GitLab path values. # These paths are assigned to `Namespace` (& `Group` as a subclass) & `Project` # -# Values are checked for formatting and exclusion from a list of reserved path +# Values are checked for formatting and exclusion from a list of illegal path # names. class DynamicPathValidator < ActiveModel::EachValidator +<<<<<<< HEAD class << self def valid_namespace_path?(path) "#{path}/" =~ Gitlab::Regex.full_namespace_path_regex @@ -13,6 +14,24 @@ class DynamicPathValidator < ActiveModel::EachValidator def valid_project_path?(path) "#{path}/" =~ Gitlab::Regex.full_project_path_regex +======= + extend Gitlab::EncodingHelper + + class << self + def valid_user_path?(path) + encode!(path) + "#{path}/" =~ Gitlab::PathRegex.root_namespace_path_regex + end + + def valid_group_path?(path) + encode!(path) + "#{path}/" =~ Gitlab::PathRegex.full_namespace_path_regex + end + + def valid_project_path?(path) + encode!(path) + "#{path}/" =~ Gitlab::PathRegex.full_project_path_regex +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end @@ -24,14 +43,21 @@ class DynamicPathValidator < ActiveModel::EachValidator case record when Project self.class.valid_project_path?(full_path) +<<<<<<< HEAD else self.class.valid_namespace_path?(full_path) +======= + when Group + self.class.valid_group_path?(full_path) + else # User or non-Group Namespace + self.class.valid_user_path?(full_path) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end def validate_each(record, attribute, value) - unless value =~ Gitlab::Regex.namespace_regex - record.errors.add(attribute, Gitlab::Regex.namespace_regex_message) + unless value =~ Gitlab::PathRegex.namespace_format_regex + record.errors.add(attribute, Gitlab::PathRegex.namespace_format_message) return end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index e1b4e34cd2b..d552704df88 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -232,7 +232,7 @@ = f.number_field :container_registry_token_expire_delay, class: 'form-control' %fieldset - %legend Metrics + %legend Metrics - Influx %p Setup InfluxDB to measure a wide variety of statistics like the time spent in running SQL queries. These settings require a @@ -297,6 +297,21 @@ results in fewer but larger UDP packets being sent. %fieldset + %legend Metrics - Prometheus + %p + Setup Prometheus to measure a variety of statistics that partially overlap and complement Influx based metrics. + This setting requires 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 + = f.label :prometheus_metrics_enabled do + = f.check_box :prometheus_metrics_enabled + Enable Prometheus Metrics + + %fieldset %legend Background Jobs %p These settings require a restart to take effect. diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index ac36bb5bb17..e5842bd1ea0 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "Background Jobs" -= render 'admin/background_jobs/head' += render 'admin/monitoring/head' %div{ class: container_class } %h3.page-title Background Jobs diff --git a/app/views/admin/conversational_development_index/_callout.html.haml b/app/views/admin/conversational_development_index/_callout.html.haml new file mode 100644 index 00000000000..33a4dab1e00 --- /dev/null +++ b/app/views/admin/conversational_development_index/_callout.html.haml @@ -0,0 +1,13 @@ +.prepend-top-default +.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } } + .bordered-box.landing.content-block + %button.btn.btn-default.close.js-close-callout{ type: 'button', + 'aria-label' => 'Dismiss ConvDev introduction' } + = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') + .user-callout-copy + %h4 + Introducing Your Conversational Development Index + %p + Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers. + .svg-container.convdev + = custom_icon('convdev_overview') diff --git a/app/views/admin/conversational_development_index/_card.html.haml b/app/views/admin/conversational_development_index/_card.html.haml new file mode 100644 index 00000000000..6c8688e06ae --- /dev/null +++ b/app/views/admin/conversational_development_index/_card.html.haml @@ -0,0 +1,25 @@ +.convdev-card-wrapper + .convdev-card{ class: "convdev-card-#{score_level(card.percentage_score)}" } + .convdev-card-title + %h3 + = card.title + .text-light + = card.description + .card-scores + .card-score + .card-score-value + = format_score(card.instance_score) + .card-score-name You + .card-score + .card-score-value + = format_score(card.leader_score) + .card-score-name Lead + .card-score-big + = number_to_percentage(card.percentage_score, precision: 1) + .card-buttons + - if card.blog + %a{ href: card.blog } + = icon('info-circle', 'aria-hidden' => 'true') + - if card.docs + %a{ href: card.docs } + = icon('question-circle', 'aria-hidden' => 'true') diff --git a/app/views/admin/conversational_development_index/_disabled.html.haml b/app/views/admin/conversational_development_index/_disabled.html.haml new file mode 100644 index 00000000000..975d7df3da6 --- /dev/null +++ b/app/views/admin/conversational_development_index/_disabled.html.haml @@ -0,0 +1,9 @@ +.container.convdev-empty + .col-sm-6.col-sm-push-3.text-center + = custom_icon('convdev_no_index') + %h4 Usage ping is not enabled + %p + ConvDev is only shown when the + = link_to 'usage ping', help_page_path('user/admin_area/settings/usage_statistics'), target: '_blank' + is enabled. Enable usage ping to get an overview of how you are using GitLab from a feature perspective + = link_to 'Enable usage ping', admin_application_settings_path(anchor: 'usage-statistics'), class: 'btn btn-primary' diff --git a/app/views/admin/conversational_development_index/_no_data.html.haml b/app/views/admin/conversational_development_index/_no_data.html.haml new file mode 100644 index 00000000000..b23d2b5ec3a --- /dev/null +++ b/app/views/admin/conversational_development_index/_no_data.html.haml @@ -0,0 +1,7 @@ +.container.convdev-empty + .col-sm-6.col-sm-push-3.text-center + = custom_icon('convdev_no_data') + %h4 Data is still calculating... + %p + In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index. + = link_to 'Learn more', help_page_path('user/admin_area/monitoring/convdev'), target: '_blank' diff --git a/app/views/admin/conversational_development_index/show.html.haml b/app/views/admin/conversational_development_index/show.html.haml new file mode 100644 index 00000000000..833d4c612f8 --- /dev/null +++ b/app/views/admin/conversational_development_index/show.html.haml @@ -0,0 +1,35 @@ +- @no_container = true +- page_title 'ConvDev Index' + += render 'admin/monitoring/head' + +.container + - if show_callout?('convdev_intro_callout_dismissed') + = render 'callout' + + .prepend-top-default + - if !current_application_settings.usage_ping_enabled + = render 'disabled' + - elsif @metric.blank? + = render 'no_data' + - else + .convdev + .convdev-header + %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" } + = number_to_percentage(@metric.average_percentage_score, precision: 1) + .convdev-header-subtitle + index + %br + score + = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/admin_area/monitoring/convdev') + + .convdev-cards.card-container + - @metric.cards.each do |card| + = render 'card', card: card + + .convdev-steps.visible-lg + - @metric.idea_to_production_steps.each_with_index do |step, index| + .convdev-step{ class: "convdev-#{score_level(step.percentage_score)}-score" } + = custom_icon("i2p_step_#{index + 1}") + %h4.convdev-step-title + = step.title diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index 163bd5662b0..dff549f502c 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -20,7 +20,7 @@ %span Groups = nav_link path: 'builds#index' do - = link_to admin_builds_path, title: 'Jobs' do + = link_to admin_jobs_path, title: 'Jobs' do %span Jobs = nav_link path: ['runners#index', 'runners#show'] do diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 53f0a1e7fde..3c9f932a225 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -79,6 +79,12 @@ = gitlab_pages %span.light.pull-right = boolean_to_icon gitlab_pages_enabled + - gitlab_shared_runners = 'Shared Runners' + - gitlab_shared_runners_enabled = Gitlab.config.gitlab_ci.shared_runners_enabled + %p{ "aria-label" => "#{gitlab_shared_runners}: status " + (gitlab_shared_runners_enabled ? "on" : "off") } + = gitlab_shared_runners + %span.light.pull-right + = boolean_to_icon gitlab_shared_runners_enabled .col-md-4 %h4 diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml new file mode 100644 index 00000000000..3a59282e578 --- /dev/null +++ b/app/views/admin/deploy_keys/edit.html.haml @@ -0,0 +1,10 @@ +- page_title 'Edit Deploy Key' +%h3.page-title Edit public deploy key +%hr + +%div + = form_for [:admin, @deploy_key], html: { class: 'deploy-key-form form-horizontal' } do |f| + = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } + .form-actions + = f.submit 'Save changes', class: 'btn-save btn' + = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel' diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml index 007da8c1d29..92370034baa 100644 --- a/app/views/admin/deploy_keys/index.html.haml +++ b/app/views/admin/deploy_keys/index.html.haml @@ -31,4 +31,6 @@ %span.cgray added #{time_ago_with_tooltip(deploy_key.created_at)} %td - = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key pull-right' + .pull-right + = link_to 'Edit', edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm' + = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key' diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml index a064efc231f..13f5259698f 100644 --- a/app/views/admin/deploy_keys/new.html.haml +++ b/app/views/admin/deploy_keys/new.html.haml @@ -1,31 +1,10 @@ -- page_title "New Deploy Key" +- page_title 'New Deploy Key' %h3.page-title New public deploy key %hr %div = form_for [:admin, @deploy_key], html: { class: 'deploy-key-form form-horizontal' } do |f| - = form_errors(@deploy_key) - - .form-group - = f.label :title, class: "control-label" - .col-sm-10= f.text_field :title, class: 'form-control' - .form-group - = f.label :key, class: "control-label" - .col-sm-10 - %p.light - Paste a machine public key here. Read more about how to generate it - = link_to "here", help_page_path("ssh/README") - = f.text_area :key, class: "form-control thin_area", rows: 5 - .form-group - .control-label - .col-sm-10 - = f.label :can_push do - = f.check_box :can_push - %strong Write access allowed - %p.light.append-bottom-0 - Allow this key to push to repository as well? (Default only allows pull access.) - + = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } .form-actions - = f.submit 'Create', class: "btn-create btn" - = link_to "Cancel", admin_deploy_keys_path, class: "btn btn-cancel" - + = f.submit 'Create', class: 'btn-create btn' + = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel' diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index 6a208d76a38..f16f59623f7 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "Health Check" -= render 'admin/background_jobs/head' += render 'admin/monitoring/head' %div{ class: container_class } %h3.page-title @@ -10,30 +10,20 @@ %p Access token is %code#health-check-token= current_application_settings.health_check_access_token - = button_to reset_health_check_token_admin_application_settings_path, - method: :put, class: 'btn btn-default', - data: { confirm: 'Are you sure you want to reset the health check token?' } do - = icon('spinner') - Reset health check access token + .prepend-top-10 + = button_to "Reset health check access token", reset_health_check_token_admin_application_settings_path, + method: :put, class: 'btn btn-default', + data: { confirm: 'Are you sure you want to reset the health check token?' } %p.light - Health information can be retrieved as plain text, JSON, or XML using: + Health information can be retrieved from the following endpoints. More information is available + = link_to 'here', help_page_path('user/admin_area/monitoring/health_check') %ul %li - %code= health_check_url(token: current_application_settings.health_check_access_token) + %code= readiness_url(token: current_application_settings.health_check_access_token) %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json) + %code= liveness_url(token: current_application_settings.health_check_access_token) %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml) - - %p.light - You can also ask for the status of specific services: - %ul - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations) + %code= metrics_url(token: current_application_settings.health_check_access_token) %hr .panel.panel-default diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml new file mode 100644 index 00000000000..7dd9943190f --- /dev/null +++ b/app/views/admin/hook_logs/_index.html.haml @@ -0,0 +1,37 @@ +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Recent Deliveries + %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong. + .col-lg-9 + - if hook_logs.any? + %table.table + %thead + %tr + %th Status + %th Trigger + %th URL + %th Elapsed time + %th Request time + %th + - hook_logs.each do |hook_log| + %tr + %td + = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } + %td.hidden-xs + %span.label.label-gray.deploy-project-label + = hook_log.trigger.singularize.titleize + %td + = truncate(hook_log.url, length: 50) + %td.light + #{number_with_precision(hook_log.execution_duration, precision: 2)} ms + %td.light + = time_ago_with_tooltip(hook_log.created_at) + %td + = link_to 'View details', admin_hook_hook_log_path(hook, hook_log) + + = paginate hook_logs, theme: 'gitlab' + + - else + .settings-message.text-center + You don't have any webhooks deliveries diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml new file mode 100644 index 00000000000..56127bacda2 --- /dev/null +++ b/app/views/admin/hook_logs/show.html.haml @@ -0,0 +1,10 @@ +- page_title 'Request details' +%h3.page-title + Request details + +%hr + += link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), class: "btn btn-default pull-right prepend-left-10" + += render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } + diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index 6217d5fb135..645005c6deb 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -18,19 +18,26 @@ or adding ssh key. But you can also enable extra triggers like Push events. .prepend-top-default + = form.check_box :repository_update_events, class: 'pull-left' + .prepend-left-20 + = form.label :repository_update_events, class: 'list-label' do + %strong Repository update events + %p.light + This URL will be triggered when repository is updated + %div = form.check_box :push_events, class: 'pull-left' .prepend-left-20 = form.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 for each branch updated to the repository %div = form.check_box :tag_push_events, class: 'pull-left' .prepend-left-20 = form.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 .form-group = form.label :enable_ssl_verification, 'SSL verification', class: 'control-label checkbox' .col-sm-10 diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml index 0777f5e2629..0e35a1905bf 100644 --- a/app/views/admin/hooks/edit.html.haml +++ b/app/views/admin/hooks/edit.html.haml @@ -12,3 +12,9 @@ = render partial: 'form', locals: { form: f, hook: @hook } .form-actions = f.submit 'Save changes', class: 'btn btn-create' + = link_to 'Test hook', test_admin_hook_path(@hook), class: 'btn btn-default' + = link_to 'Remove', admin_hook_path(@hook), method: :delete, class: 'btn btn-remove pull-right', data: { confirm: 'Are you sure?' } + +%hr + += render partial: 'admin/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs } diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index 71117758921..e92b8bc39f4 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -27,7 +27,7 @@ = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm' .monospace= hook.url %div - - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events).each do |trigger| + - %w(repository_update_events push_events tag_push_events issues_events note_events merge_requests_events job_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray= trigger.titleize %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/jobs/index.html.haml index 66d633119c2..09be17f07be 100644 --- a/app/views/admin/builds/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -4,15 +4,15 @@ %div{ class: container_class } .top-area - - build_path_proc = ->(scope) { admin_builds_path(scope: scope) } + - build_path_proc = ->(scope) { admin_jobs_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? - = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post + = link_to 'Cancel all', cancel_all_admin_jobs_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post .row-content-block.second-block #{(@scope || 'all').capitalize} jobs %ul.content-list.builds-content-list.admin-builds-table - = render "projects/builds/table", builds: @builds, admin: true + = render "projects/jobs/table", builds: @builds, admin: true diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml index 5e585ce789b..487f1cf5c4f 100644 --- a/app/views/admin/logs/show.html.haml +++ b/app/views/admin/logs/show.html.haml @@ -3,7 +3,7 @@ - loggers = [Gitlab::GitLogger, Gitlab::AppLogger, Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger, Gitlab::RepositoryCheckLogger] -= render 'admin/background_jobs/head' += render 'admin/monitoring/head' %div{ class: container_class } %ul.nav-links.log-tabs diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/monitoring/_head.html.haml index b3530915068..901e30275fd 100644 --- a/app/views/admin/background_jobs/_head.html.haml +++ b/app/views/admin/monitoring/_head.html.haml @@ -3,6 +3,10 @@ = render 'shared/nav_scroll' .nav-links.sub-nav.scrolling-tabs %ul{ class: (container_class) } + = nav_link(controller: :conversational_development_index) do + = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do + %span + ConvDev Index = nav_link(controller: :system_info) do = link_to admin_system_info_path, title: 'System Info' do %span diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml index ae918086a57..b7db18b2d32 100644 --- a/app/views/admin/requests_profiles/index.html.haml +++ b/app/views/admin/requests_profiles/index.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title 'Requests Profiles' -= render 'admin/background_jobs/head' += render 'admin/monitoring/head' %div{ class: container_class } %h3.page-title @@ -20,7 +20,7 @@ %ul.content-list - profiles.each do |profile| %li - = link_to profile.time.to_s(:long), admin_requests_profile_path(profile), data: {no_turbolink: true} + = link_to profile.time.to_s(:long), admin_requests_profile_path(profile) - else %p No profiles found diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index f118804cace..e242e851b4d 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -17,12 +17,10 @@ .pull-left %p You can reset runners registration token by pressing a button below. - %p - = button_to reset_runners_token_admin_application_settings_path, + .prepend-top-10 + = button_to "Reset runners registration token", reset_runners_token_admin_application_settings_path, method: :put, class: 'btn btn-default', - data: { confirm: 'Are you sure you want to reset registration token?' } do - = icon('spinner') - Reset runners registration token + data: { confirm: 'Are you sure you want to reset registration token?' } .bs-callout %p diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index dc4116e1ce0..801430e525e 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -85,7 +85,7 @@ %tr.build %td.id - if project - = link_to namespace_project_build_path(project.namespace, project, build) do + = link_to namespace_project_job_path(project.namespace, project, build) do %strong ##{build.id} - else %strong ##{build.id} diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml index 2e5f120c4e4..fd0281e4961 100644 --- a/app/views/admin/system_info/show.html.haml +++ b/app/views/admin/system_info/show.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "System Info" -= render 'admin/background_jobs/head' += render 'admin/monitoring/head' %div{ class: container_class } .prepend-top-default @@ -31,3 +31,8 @@ %h1 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])} %p= disk[:disk_name] %p= disk[:mount_path] + .col-sm-4 + .light-well + %h4 Uptime + .data + %h1= time_ago_with_tooltip(Rails.application.config.booted_at) diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index 8862455688f..4cf4a57ba18 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -34,9 +34,15 @@ - if user.access_locked? %li = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' } - - if user.can_be_removed? && can?(current_user, :destroy_user, @user) + - if can?(current_user, :destroy_user, user) %li.divider + - if user.can_be_removed? + %li + = link_to 'Remove user', admin_user_path(user), + data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, + method: :delete %li - = link_to 'Delete user', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" }, - class: 'btn btn-remove btn-block', - method: :delete + = link_to 'Remove user and contributions', admin_user_path(user, hard_delete: true), + data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and comments authored by this user, and groups owned solely by them, will also be removed! Are you sure?" }, + class: 'btn btn-remove btn-block', + method: :delete diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 89d0bbb7126..b556ff056c0 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -177,7 +177,7 @@ %p Deleting a user has the following effects: = render 'users/deletion_guidance', user: @user %br - = link_to 'Remove user', [:admin, @user], data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" + = link_to 'Remove user', admin_user_path(@user), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" - else - if @user.solo_owned_groups.present? %p @@ -188,3 +188,22 @@ - else %p You don't have access to delete this user. + + .panel.panel-danger + .panel-heading + Remove user and contributions + .panel-body + - if can?(current_user, :destroy_user, @user) + %p + This option deletes the user and any contributions that + would usually be moved to the + = succeed "." do + = link_to "system ghost user", help_page_path("user/profile/account/delete_account") + As well as the user's personal projects, groups owned solely by + the user, and projects in them, will also be removed. Commits + to other projects are unaffected. + %br + = link_to 'Remove user and contributions', admin_user_path(@user, hard_delete: true), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" + - else + %p + You don't have access to delete this user. diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 9aabfb49a29..5f07d2720c2 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -12,9 +12,9 @@ - if current_user .award-menu-holder.js-award-holder %button.btn.award-control.has-tooltip.js-add-award{ type: 'button', - 'aria-label': 'Add emoji', + 'aria-label': 'Add reaction', class: ("js-user-authored" if user_authored), - data: { title: 'Add emoji', placement: "bottom" } } + data: { title: 'Add reaction', placement: "bottom" } } %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley') %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile') diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index 89d991abe54..a676eba2aee 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -1,7 +1,4 @@ -.hidden-xs - = render "events/event_last_push", event: @last_push - -.nav-block +.nav-block.activities .controls = link_to dashboard_projects_path(rss_url_options), class: 'btn rss-btn has-tooltip', title: 'Subscribe' do %i.fa.fa-rss diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml index 190ad4b40a5..f893c3e1675 100644 --- a/app/views/dashboard/activity.html.haml +++ b/app/views/dashboard/activity.html.haml @@ -1,10 +1,16 @@ +- @no_container = true + = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") - page_title "Activity" - header_title "Activity", activity_dashboard_path -= render 'dashboard/activity_head' +.hidden-xs + = render "projects/last_push" + +%div{ class: container_class } + = render 'dashboard/activity_head' -%section.activities - = render 'activities' + %section.activities + = render 'activities' diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index faa68468043..d6b46dee0e4 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -8,7 +8,7 @@ .nav-controls = link_to params.merge(rss_url_options), class: 'btn has-tooltip', title: 'Subscribe' do = icon('rss') - = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue" + = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues' = render 'shared/issuable/filter', type: :issues = render 'shared/issues' diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 12966c01950..6f6afe161d1 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -4,7 +4,7 @@ .top-area = render 'shared/issuable/nav', type: :merge_requests .nav-controls - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request" + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests' = render 'shared/issuable/filter', type: :merge_requests = render 'shared/merge_requests' diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 596499230f9..5e63a61e21b 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -1,19 +1,21 @@ +- @no_container = true + = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") - page_title "Projects" - header_title "Projects", dashboard_projects_path -- unless show_user_callout? - = render 'shared/user_callout' += render "projects/last_push" -- if @projects.any? || params[:name] - = render 'dashboard/projects_head' +%div{ class: container_class } + - if show_callout?('user_callout_dismissed') + = render 'shared/user_callout' -- if @last_push - = render "events/event_last_push", event: @last_push + - if @projects.any? || params[:name] + = render 'dashboard/projects_head' -- if @projects.any? || params[:name] - = render 'projects' -- else - = render "zero_authorized_projects" + - if @projects.any? || params[:name] + = render 'projects' + - else + = render "zero_authorized_projects" diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index 162ae153b1c..99efe9c9b86 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -1,13 +1,15 @@ +- @no_container = true + - page_title "Starred Projects" - header_title "Projects", dashboard_projects_path -= render 'dashboard/projects_head' += render "projects/last_push" -- if @last_push - = render "events/event_last_push", event: @last_push +%div{ class: container_class } + = render 'dashboard/projects_head' -- if @projects.any? || params[:filter_projects] - = render 'projects' -- else - %h3 You don't have starred projects yet - %p.slead Visit project page and press on star icon and it will appear on this page. + - if @projects.any? || params[:filter_projects] + = render 'projects' + - else + %h3 You don't have starred projects yet + %p.slead Visit project page and press on star icon and it will appear on this page. diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 5e189e6dc54..eb0e6701627 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -6,10 +6,10 @@ = devise_error_messages! = f.hidden_field :reset_password_token .form-group - = f.label 'New password', for: :password + = f.label 'New password', for: "user_password" = f.password_field :password, class: "form-control top", required: true, title: 'This field is required' .form-group - = f.label 'Confirm new password', for: :password_confirmation + = f.label 'Confirm new password', for: "user_password_confirmation" = f.password_field :password_confirmation, class: "form-control bottom", title: 'This field is required', required: true .clearfix = f.submit "Change your password", class: "btn btn-primary" diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 21c751a23f8..4095f30c369 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,6 +1,6 @@ = form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f| .form-group - = f.label "Username or email", for: :login + = f.label "Username or email", for: "user_login" = f.text_field :login, class: "form-control top", autofocus: "autofocus", autocapitalize: "off", autocorrect: "off", required: true, title: "This field is required." .form-group = f.label :password diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index a2f6a7ab1cb..d696577278d 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -8,7 +8,7 @@ = f.text_field :name, class: "form-control top", required: true, title: "This field is required." .username.form-group = f.label :username - = f.text_field :username, class: "form-control middle", pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS, required: true, title: 'Please create a username with only alphanumeric characters.' + = f.text_field :username, class: "form-control middle", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: 'Please create a username with only alphanumeric characters.' %p.validation-error.hide Username is already taken. %p.validation-success.hide Username is available. %p.validation-pending.hide Checking username availability... diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 78c5b0c1dda..70042dee20f 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -3,7 +3,7 @@ .diff-file.file-holder .js-file-title.file-title - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_path(discussion) + = render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false .diff-content.code.js-syntax-highlight %table diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 38e85168f40..578e751ab47 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -26,16 +26,15 @@ - commit = discussion.noteable - if commit commit - = link_to commit.short_id, url, class: 'monospace' + = link_to commit.short_id, url, class: 'commit-sha' - else a deleted commit - elsif discussion.diff_discussion? on = conditional_link_to url.present?, url do - - if discussion.active? - the diff - - else - an outdated diff + - unless discussion.active? + an old version of + the diff = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") = render "discussions/headline", discussion: discussion diff --git a/app/views/discussions/_jump_to_next.html.haml b/app/views/discussions/_jump_to_next.html.haml index 69bd416c4de..3db509f24a5 100644 --- a/app/views/discussions/_jump_to_next.html.haml +++ b/app/views/discussions/_jump_to_next.html.haml @@ -3,7 +3,7 @@ %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", + ":title" => "buttonText", + ":aria-label" => "buttonText", data: { container: "body" } } = custom_icon("next_discussion") diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 7ba3f3f6c42..db5ab939948 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,10 +1,11 @@ .discussion-notes %ul.notes{ data: { discussion_id: discussion.id } } = render partial: "shared/notes/note", collection: discussion.notes, as: :note - .flash-container - - if current_user - .discussion-reply-holder + .flash-container + + .discussion-reply-holder + - if can_create_note? - if discussion.potentially_resolvable? - line_type = local_assigns.fetch(:line_type, nil) @@ -19,3 +20,10 @@ = render "discussions/jump_to_next", discussion: discussion - else = link_to_reply_discussion(discussion) + - elsif !current_user + .disabled-comment.text-center + Please + = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') + or + = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') + to reply diff --git a/app/views/events/_commit.html.haml b/app/views/events/_commit.html.haml index 1bc9f604438..3c64f1be5ff 100644 --- a/app/views/events/_commit.html.haml +++ b/app/views/events/_commit.html.haml @@ -1,5 +1,5 @@ %li.commit .commit-row-title - = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '', title: truncate_sha(commit[:id]) + = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit-sha", alt: '', title: truncate_sha(commit[:id]) · = markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line, author: event.author diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml deleted file mode 100644 index 1584695a62b..00000000000 --- a/app/views/events/_event_last_push.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- if show_last_push_widget?(event) - .row-content-block.clear-block.last-push-widget - .event-last-push - .event-last-push-text - %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.project.name) do - %strong= event.ref_name - %span at - %strong= link_to_project event.project - #{time_ago_with_tooltip(event.created_at)} - - .pull-right - = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do - Create merge request diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index c0943100ae3..769ac655d0a 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -7,7 +7,7 @@ %span.pushed #{event.action_name} #{event.ref_type} %strong - commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name) - = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link + = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link, class: 'ref-name' = render "events/event_scope", event: event diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index d7851c79990..fd6e7111f38 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -1,6 +1,3 @@ -.hidden-xs - = render "events/event_last_push", event: @last_push - .nav-block .controls = link_to group_path(@group, rss_url_options), class: 'btn rss-btn has-tooltip' , title: 'Subscribe' do diff --git a/app/views/groups/_head.html.haml b/app/views/groups/_head.html.haml index 873504099d4..0f63774fb9b 100644 --- a/app/views/groups/_head.html.haml +++ b/app/views/groups/_head.html.haml @@ -12,3 +12,6 @@ = link_to activity_group_path(@group), title: 'Activity' do %span Activity + +.hidden-xs + = render "projects/last_push" diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 18997baa998..80a8ba4a755 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -6,7 +6,6 @@ = render 'groups/head' = render 'groups/home_panel' - .groups-header{ class: container_class } .top-area = render 'groups/show_nav' diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 9999a4362c6..c52a515226e 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -46,6 +46,3 @@ .form-actions = submit_tag 'Continue to the next step', class: 'btn btn-create' - -:javascript - new UsersSelect(); diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 9e354987401..1ef0d524dbb 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -33,6 +33,7 @@ = webpack_bundle_tag "runtime" = webpack_bundle_tag "common" + = webpack_bundle_tag "locale" = webpack_bundle_tag "main" = webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled = webpack_bundle_tag "test" if Rails.env.test? diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml index 769f6fb0151..6caaba240bb 100644 --- a/app/views/layouts/_init_auto_complete.html.haml +++ b/app/views/layouts/_init_auto_complete.html.haml @@ -3,6 +3,7 @@ - if project :javascript + gl.GfmAutoComplete = gl.GfmAutoComplete || {}; gl.GfmAutoComplete.dataSources = { members: "#{members_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}", issues: "#{issues_namespace_project_autocomplete_sources_path(project.namespace, project)}", @@ -11,5 +12,3 @@ milestones: "#{milestones_namespace_project_autocomplete_sources_path(project.namespace, project)}", commands: "#{commands_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}" }; - - gl.GfmAutoComplete.setup(); diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 0e64ebd71b8..b689991bb6d 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -13,7 +13,7 @@ .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 no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url } + = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url }, aria: { label: 'Search' } .dropdown-menu.dropdown-select = dropdown_content do %ul diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 7e011ac3e75..03688e9ff21 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -2,8 +2,8 @@ %html{ lang: I18n.locale, class: "#{page_class}" } = render "layouts/head" %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } } + = render "layouts/init_auto_complete" if @gfm_form = render "layouts/header/default", title: header_title = render 'layouts/page', sidebar: sidebar, nav: nav = yield :scripts_body - = render "layouts/init_auto_complete" if @gfm_form diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 659d548df18..249253f4906 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,4 +1,5 @@ %header.navbar.navbar-gitlab{ class: nav_header_class } + .navbar-border %a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content .container-fluid .header-content @@ -35,10 +36,7 @@ %li = link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('wrench fw') - - if current_user.can_create_project? - %li - = link_to new_project_path, title: 'New project', aria: { label: "New project" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = icon('plus fw') + = render 'layouts/header/new_dropdown' - if Gitlab::Sherlock.enabled? %li = link_to sherlock_transactions_path, title: 'Sherlock Transactions', @@ -73,12 +71,12 @@ @#{current_user.username} %li.divider %li - = link_to "Profile", current_user, class: 'profile-link', aria: { label: "Profile" }, data: { user: current_user.username } + = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li - = link_to "Settings", profile_path, aria: { label: "Settings" } + = link_to "Settings", profile_path %li.divider %li - = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link", aria: { label: "Sign out" } + = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" - else %li %div diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml new file mode 100644 index 00000000000..c7302414386 --- /dev/null +++ b/app/views/layouts/header/_new_dropdown.haml @@ -0,0 +1,45 @@ +%li.header-new.dropdown + = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do + = icon('plus fw') + = icon('caret-down') + .dropdown-menu-nav.dropdown-menu-align-right + %ul + - if @group + - create_group_project = can?(current_user, :create_projects, @group) + - create_group_subgroup = can?(current_user, :create_subgroup, @group) + - if create_group_project || create_group_subgroup + %li.dropdown-bold-header This group + - if create_group_project + %li.header-new-group-project + = link_to 'New project', new_project_path(namespace_id: @group.id) + - if create_group_subgroup + %li + = link_to 'New subgroup', new_group_path(parent_id: @group.id) + %li.divider + %li.dropdown-bold-header GitLab + + - if @project && @project.persisted? + - create_project_issue = can?(current_user, :create_issue, @project) + - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) + - create_project_snippet = can?(current_user, :create_project_snippet, @project) + - if create_project_issue || merge_project || create_project_snippet + %li.dropdown-bold-header This project + - if create_project_issue + %li + = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project) + - if merge_project + %li + = link_to 'New merge request', new_namespace_project_merge_request_path(merge_project.namespace, merge_project) + - if create_project_snippet + %li.header-new-project-snippet + = link_to 'New snippet', new_namespace_project_snippet_path(@project.namespace, @project) + %li.divider + %li.dropdown-bold-header GitLab + - if current_user.can_create_project? + %li + = link_to 'New project', new_project_path + - if current_user.can_create_group? + %li + = link_to 'New group', new_group_path + %li + = link_to 'New snippet', new_snippet_path diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index d068c895fa3..6df0adfd742 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -5,19 +5,19 @@ .fade-right = icon('angle-right') %ul.nav-links.scrolling-tabs - = nav_link(controller: %w(dashboard admin projects users groups builds runners), html_options: {class: 'home'}) do + = nav_link(controller: %w(dashboard admin projects users groups builds runners cohorts), html_options: {class: 'home'}) do = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview - = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do - = link_to admin_system_info_path, title: 'Monitoring' do + = nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do + = link_to admin_conversational_development_index_path, title: 'Monitoring' do %span Monitoring = nav_link(controller: :broadcast_messages) do = link_to admin_broadcast_messages_path, title: 'Messages' do %span Messages - = nav_link(controller: :hooks) do + = nav_link(controller: [:hooks, :hook_logs]) do = link_to admin_hooks_path, title: 'Hooks' do %span System Hooks diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index e06301bda14..ae1e1361f0f 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -48,6 +48,6 @@ %span Preferences = nav_link(path: 'profiles#audit_log') do - = link_to audit_log_profile_path, title: 'Audit Log' do + = link_to audit_log_profile_path, title: 'Authentication log' do %span - Audit Log + Authentication log diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index e4dfe0c8c08..29658da7792 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -92,7 +92,7 @@ -# Shortcut to Pipelines > Jobs - if project_nav_tab? :builds %li.hidden - = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do + = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do Jobs -# Shortcut to commits page diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index 98b75cea03f..57971205e0e 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -1,9 +1,8 @@ - header_title "Snippets", snippets_path - content_for :page_specific_javascripts do - - if @snippet&.persisted? && current_user + - if @snippet && current_user :javascript - window.uploads_path = "#{upload_path('personal_snippet', @snippet)}"; - window.preview_markdown_path = "#{preview_markdown_snippet_path(@snippet)}"; + window.uploads_path = "#{upload_path('personal_snippet', id: @snippet.id)}"; = render template: "layouts/application" diff --git a/app/views/notify/links/ci/builds/_build.html.haml b/app/views/notify/links/ci/builds/_build.html.haml index d35b3839171..644cf506eff 100644 --- a/app/views/notify/links/ci/builds/_build.html.haml +++ b/app/views/notify/links/ci/builds/_build.html.haml @@ -1,2 +1,2 @@ -%a{ href: pipeline_build_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" } +%a{ href: pipeline_job_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" } = build.name diff --git a/app/views/notify/links/ci/builds/_build.text.erb b/app/views/notify/links/ci/builds/_build.text.erb index 741c7f344c8..773ae8174e9 100644 --- a/app/views/notify/links/ci/builds/_build.text.erb +++ b/app/views/notify/links/ci/builds/_build.text.erb @@ -1 +1 @@ -Job #<%= build.id %> ( <%= pipeline_build_url(pipeline, build) %> ) +Job #<%= build.id %> ( <%= pipeline_job_url(pipeline, build) %> ) diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index 02eb7c8462c..546376aeed8 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -27,40 +27,38 @@ %h4 #{pluralize @message.diffs_count, "changed file"}: %ul - - @message.diffs.each do |diff| + - @message.diffs.each do |diff_file| %li.file-stats - %a{ href: "#{@message.target_url if @message.disable_diffs?}##{hexdigest(diff.file_path)}" } - - if diff.deleted_file + %a{ href: "#{@message.target_url if @message.disable_diffs?}##{hexdigest(diff_file.file_path)}" } + - if diff_file.deleted_file? %span.deleted-file − - = diff.old_path - - elsif diff.renamed_file - = diff.old_path + = diff_file.old_path + - elsif diff_file.renamed_file? + = diff_file.old_path → - = diff.new_path - - elsif diff.new_file + = diff_file.new_path + - elsif diff_file.new_file? %span.new-file + - = diff.new_path + = diff_file.new_path - else - = diff.new_path + = diff_file.new_path - unless @message.disable_diffs? - - diff_files = @message.diffs - - if @message.compare_timeout %h5 The diff was not included because it is too large. - else %h4 Changes: - - diff_files.each do |diff_file| + - @message.diffs.each do |diff_file| - file_hash = hexdigest(diff_file.file_path) %li{ id: file_hash } %a{ href: @message.target_url + "##{file_hash}" }< - - if diff_file.deleted_file + - if diff_file.deleted_file? %strong< = diff_file.old_path deleted - - elsif diff_file.renamed_file + - elsif diff_file.renamed_file? %strong< = diff_file.old_path → diff --git a/app/views/notify/repository_push_email.text.haml b/app/views/notify/repository_push_email.text.haml index 5ac23aa3997..895d8807e47 100644 --- a/app/views/notify/repository_push_email.text.haml +++ b/app/views/notify/repository_push_email.text.haml @@ -15,15 +15,15 @@ \ #{pluralize @message.diffs_count, "changed file"}: \ - - @message.diffs.each do |diff| - - if diff.deleted_file - \- − #{diff.old_path} - - elsif diff.renamed_file - \- #{diff.old_path} → #{diff.new_path} - - elsif diff.new_file - \- + #{diff.new_path} + - @message.diffs.each do |diff_file| + - if diff_file.deleted_file? + \- − #{diff_file.old_path} + - elsif diff_file.renamed_file? + \- #{diff_file.old_path} → #{diff_file.new_path} + - elsif diff_file.new_file? + \- + #{diff_file.new_path} - else - \- #{diff.new_path} + \- #{diff_file.new_path} - unless @message.disable_diffs? - if @message.compare_timeout \ @@ -36,9 +36,9 @@ - @message.diffs.each do |diff_file| \ \===================================== - - if diff_file.deleted_file + - if diff_file.deleted_file? #{diff_file.old_path} deleted - - elsif diff_file.renamed_file + - elsif diff_file.renamed_file? #{diff_file.old_path} → #{diff_file.new_path} - else = diff_file.new_path diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml index 879fc170f92..d0ad90ac6cc 100644 --- a/app/views/profiles/_event_table.html.haml +++ b/app/views/profiles/_event_table.html.haml @@ -9,7 +9,6 @@ Signed in with = event.details[:with] authentication - %span.pull-right - #{time_ago_in_words event.created_at} ago + %span.pull-right= time_ago_with_tooltip(event.created_at) = paginate events, theme: "gitlab" diff --git a/app/views/profiles/accounts/_reset_token.html.haml b/app/views/profiles/accounts/_reset_token.html.haml new file mode 100644 index 00000000000..c31a4a8ecd4 --- /dev/null +++ b/app/views/profiles/accounts/_reset_token.html.haml @@ -0,0 +1,11 @@ +- name = label.parameterize +- attribute = name.underscore + +.reset-action + %p.cgray + = label_tag name, label, class: "label-light" + = text_field_tag name, current_user.send(attribute), class: 'form-control', readonly: true, onclick: 'this.select()' + %p.help-block + = help_text + .prepend-top-default + = link_to button_label, [:reset, attribute, :profile], method: :put, data: { confirm: 'Are you sure?' }, class: 'btn btn-default private-token' diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 73f33e69d68..a319b18e507 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -8,35 +8,17 @@ .row.prepend-top-default .col-lg-3.profile-settings-sidebar %h4.prepend-top-0 - = incoming_email_token_enabled? ? "Private Tokens" : "Private Token" + Private Tokens %p - Keep - = incoming_email_token_enabled? ? "these tokens" : "this token" - secret, anyone with access to them can interact with GitLab as if they were you. + Keep these tokens secret, anyone with access to them can interact with + GitLab as if they were you. .col-lg-9.private-tokens-reset - .reset-action - %p.cgray - - if current_user.private_token - = label_tag "private-token", "Private token", class: "label-light" - = text_field_tag "private-token", current_user.private_token, class: "form-control", readonly: true, onclick: "this.select()" - - else - %span You don't have one yet. Click generate to fix it. - %p.help-block - Your private token is used to access the API and Atom feeds without username/password authentication. - .prepend-top-default - - if current_user.private_token - = link_to 'Reset private token', reset_private_token_profile_path, method: :put, data: { confirm: "Are you sure?" }, class: "btn btn-default private-token" - - else - = f.submit 'Generate', class: "btn btn-default" + = render partial: 'reset_token', locals: { label: 'Private token', button_label: 'Reset private token', help_text: 'Your private token is used to access the API and Atom feeds without username/password authentication.' } + + = render partial: 'reset_token', locals: { label: 'RSS token', button_label: 'Reset RSS token', help_text: 'Your RSS token is used to create urls for personalized RSS feeds.' } + - if incoming_email_token_enabled? - .reset-action - %p.cgray - = label_tag "incoming-email-token", "Incoming Email Token", class: 'label-light' - = text_field_tag "incoming-email-token", current_user.incoming_email_token, class: "form-control", readonly: true, onclick: "this.select()" - %p.help-block - Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses. - .prepend-top-default - = link_to 'Reset incoming email token', reset_incoming_email_token_profile_path, method: :put, data: { confirm: "Are you sure?" }, class: "btn btn-default incoming-email-token" + = render partial: 'reset_token', locals: { label: 'Incoming email token', button_label: 'Reset incoming email token', help_text: 'Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses.' } %hr .row.prepend-top-default diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index 9fe86e6b291..a24b7fd101d 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,4 +1,4 @@ -- page_title "Audit Log" +- page_title "Authentication log" = render 'profiles/head' .row.prepend-top-default diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 99690e6b98a..0ff19b3eab1 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -37,9 +37,9 @@ = f.select :dashboard, dashboard_choices, {}, class: 'form-control' .form-group = f.label :project_view, class: 'label-light' do - Project view + Project home page content = f.select :project_view, project_view_choices, {}, class: 'form-control' .help-block - Choose what content you want to see on a project's home page. + Choose what content you want to see on a project’s home page .form-group = f.submit 'Save changes', class: 'btn btn-save' diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 4a1438aa68e..fcfd350f0da 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -49,10 +49,10 @@ .form-group = f.label :email, class: "label-light" - - if @user.ldap_user? && @user.ldap_email? + - if @user.external_email? = f.text_field :email, class: "form-control", required: true, readonly: true %span.help-block.light - Your email address was automatically set based on the LDAP server. + Your email address was automatically set based on your #{email_provider_label} account. - else - if @user.temp_oauth_email? = f.text_field :email, class: "form-control", required: true, value: nil diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index aa0cb3e1a50..10f581d751b 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -1,7 +1,5 @@ -- @no_container = true - %div{ class: container_class } - .nav-block.activity-filter-block + .nav-block.activity-filter-block.activities .controls = link_to namespace_project_path(@project.namespace, @project, rss_url_options), title: "Subscribe", class: 'btn rss-btn has-tooltip' do = icon('rss') diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 96c2fa87f45..426085b3e1c 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -1,6 +1,14 @@ +- commit = local_assigns.fetch(:commit) { @repository.commit } +- ref = local_assigns.fetch(:ref) { current_ref } +- project = local_assigns.fetch(:project) { @project } #tree-holder.tree-holder.clearfix .nav-block = render 'projects/tree/tree_header', tree: @tree - = render 'projects/tree/tree_content', tree: @tree + - if commit + .info-well.hidden-xs.project-last-commit.append-bottom-default + .well-segment + %ul.blob-commit-info + = render 'projects/commits/commit', commit: commit, ref: ref, project: project + = render 'projects/tree/tree_content', tree: @tree diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml index 3feb11645a0..c748ccf65e6 100644 --- a/app/views/projects/_find_file_link.html.haml +++ b/app/views/projects/_find_file_link.html.haml @@ -1,3 +1,3 @@ = link_to namespace_project_find_file_path(@project.namespace, @project, @ref), class: 'btn btn-grouped shortcuts-find-file', rel: 'nofollow' do = icon('search') - %span Find file + %span= _('Find file') diff --git a/app/views/projects/_head.html.haml b/app/views/projects/_head.html.haml index db08b77c8e0..dba84838a52 100644 --- a/app/views/projects/_head.html.haml +++ b/app/views/projects/_head.html.haml @@ -4,17 +4,14 @@ .nav-links.sub-nav.scrolling-tabs %ul{ class: container_class } = nav_link(path: 'projects#show') do - = link_to project_path(@project), title: 'Project home', class: 'shortcuts-project' do - %span - Home + = link_to project_path(@project), title: _('Project home'), class: 'shortcuts-project' do + %span= _('Home') = nav_link(path: 'projects#activity') do - = link_to activity_project_path(@project), title: 'Activity', class: 'shortcuts-project-activity' do - %span - Activity + = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do + %span= _('Activity') - if can?(current_user, :read_cycle_analytics, @project) = nav_link(path: 'cycle_analytics#show') do - = link_to project_cycle_analytics_path(@project), title: 'Cycle Analytics', class: 'shortcuts-project-cycle-analytics' do - %span - Cycle Analytics + = link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do + %span= _('Cycle Analytics') diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 0fd19780570..873b3045ea9 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -14,7 +14,7 @@ - if forked_from_project = @project.forked_from_project %p - Forked from + #{ s_('ForkedFromProjectPath|Forked from') } = link_to project_path(forked_from_project) do = forked_from_project.namespace.try(:name) @@ -24,7 +24,7 @@ = render 'projects/buttons/fork' %span.hidden-xs - - if @project.feature_available?(:repository, current_user) + - if can?(current_user, :download_code, @project) .project-clone-holder = render "shared/clone_panel" diff --git a/app/views/projects/_last_commit.html.haml b/app/views/projects/_last_commit.html.haml deleted file mode 100644 index df3b1c75508..00000000000 --- a/app/views/projects/_last_commit.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -- ref = local_assigns.fetch(:ref) -- status = commit.status(ref) -- if status - = link_to pipelines_namespace_project_commit_path(commit.project.namespace, commit.project, commit), class: "ci-status ci-#{status}" do - = ci_icon_for_status(status) - = ci_text_for_status(status) - -= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" -= link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit), class: "commit-row-message" -· -#{time_ago_with_tooltip(commit.committed_date)} by -= commit_author_link(commit, avatar: true, size: 24) diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 768bc1fb323..f1ef50d2de2 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -1,18 +1,18 @@ -- if event = last_push_event - - if show_last_push_widget?(event) - .row-content-block.top-block.hidden-xs.white - %div{ class: container_class } - .event-last-push - .event-last-push-text - %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do - %strong= event.ref_name - - if @project && event.project != @project - %span at - %strong= link_to_project event.project - = clipboard_button(text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard') - #{time_ago_with_tooltip(event.created_at)} +- event = last_push_event +- if event && show_last_push_widget?(event) + .row-content-block.top-block.hidden-xs.white + .event-last-push + .event-last-push-text + %span You pushed to + %strong + = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), class: 'ref-name' - .pull-right - = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do - Create merge request + - if event.project != @project + %span at + %strong= link_to_project event.project + + #{time_ago_with_tooltip(event.created_at)} + + .pull-right + = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do + #{ _('Create merge request') } diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index d0698285f84..07445434cf3 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -9,12 +9,6 @@ %li %a.js-md-preview-button{ href: "#md-preview-holder", tabindex: -1 } Preview - - - if defined?(@issue) && @issue.confidential? - %li.confidential-issue-warning - = icon('warning') - %span This is a confidential issue. Your comment will not be visible to the public. - %li.pull-right .toolbar-group = markdown_toolbar_button({ icon: "bold fw", data: { "md-tag" => "**" }, title: "Add bold text" }) diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml deleted file mode 100644 index c0d12cbc66e..00000000000 --- a/app/views/projects/_readme.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -- if readme = @repository.readme - %article.readme-holder - .pull-right - - if can?(current_user, :push_code, @project) - = link_to icon('pencil'), namespace_project_edit_blob_path(@project.namespace, @project, tree_join(@repository.root_ref, readme.name)), class: 'light edit-project-readme' - .file-content.wiki - = markup(readme.name, readme.data, rendered: @repository.rendered_readme) -- else - .row-content-block.second-block.center - %h3.page-title - This project does not have a README yet - - if can?(current_user, :push_code, @project) - %p - A - %code README - file contains information about other files in a repository and is commonly - distributed with computer software, forming part of its documentation. - %p - We recommend you to - = link_to "add a README", add_special_file_path(@project, file_name: 'README.md'), class: 'underlined-link' - file to the repository and GitLab will render it here instead of this message. diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index 0c8241053e7..3b3d08ddd3c 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -1,10 +1,11 @@ - @gfm_form = true +- current_text ||= nil - 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, data: { supports_slash_commands: supports_slash_commands } - else - = text_area_tag attr, nil, class: classes, placeholder: placeholder + = text_area_tag attr, current_text, class: classes, placeholder: placeholder %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } = icon('compress') diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml index 27c8e3c7fca..ef8d8051cbf 100644 --- a/app/views/projects/activity.html.haml +++ b/app/views/projects/activity.html.haml @@ -1,3 +1,5 @@ +- @no_container = true + - page_title "Activity" = render "projects/head" diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml index 34d5c3b7285..e2966ec33c2 100644 --- a/app/views/projects/artifacts/_tree_directory.html.haml +++ b/app/views/projects/artifacts/_tree_directory.html.haml @@ -1,4 +1,4 @@ -- path_to_directory = browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: directory.path) +- path_to_directory = browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: directory.path) %tr.tree-item{ 'data-link' => path_to_directory } %td.tree-item-file-name diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index ce7e25d774b..ea0b43b85cf 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,4 +1,4 @@ -- path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) +- path_to_file = file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: file.path) %tr.tree-item{ 'data-link' => path_to_file } - blob = file.blob diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index 9fbb30f7c7c..961c805dc7c 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -1,22 +1,22 @@ - page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' = render "projects/pipelines/head" -= render "projects/builds/header", show_controls: false += render "projects/jobs/header", show_controls: false .tree-holder .nav-block .tree-controls - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), + = link_to download_namespace_project_job_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-default download' do = icon('download') Download artifacts archive %ul.breadcrumb.repo-breadcrumb %li - = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + = link_to 'Artifacts', browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build) - path_breadcrumbs do |title, path| %li - = link_to truncate(title, length: 40), browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + = link_to truncate(title, length: 40), browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) .tree-content-holder %table.table.tree-table diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml index d8da83b9a80..b25c7c95196 100644 --- a/app/views/projects/artifacts/file.html.haml +++ b/app/views/projects/artifacts/file.html.haml @@ -1,21 +1,21 @@ - page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' = render "projects/pipelines/head" -= render "projects/builds/header", show_controls: false += render "projects/jobs/header", show_controls: false #tree-holder.tree-holder .nav-block %ul.breadcrumb.repo-breadcrumb %li - = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + = link_to 'Artifacts', browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build) - path_breadcrumbs do |title, path| - title = truncate(title, length: 40) %li - if path == @path - = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do + = link_to file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) do %strong= title - else - = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + = link_to title, browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) %article.file-holder diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 35885b2c7b4..a6ee2b2f7b8 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -1,11 +1,11 @@ - @no_container = true -- page_title "Blame", @blob.path, @ref +- page_title "Annotate", @blob.path, @ref = render "projects/commits/head" %div{ class: container_class } - %h3.page-title Blame view - #blob-content-holder.tree-holder + = render "projects/blob/breadcrumb", blob: @blob, blame: true + .file-holder = render "projects/blob/header", blob: @blob, blame: true @@ -22,7 +22,7 @@ %strong = link_to_gfm truncate(commit.title, length: 35), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "cdark" .pull-right - = link_to commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit), class: "monospace" + = link_to commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit), class: "commit-sha" .light = commit_author_link(commit, avatar: false) diff --git a/app/views/projects/blob/_auxiliary_viewer.html.haml b/app/views/projects/blob/_auxiliary_viewer.html.haml new file mode 100644 index 00000000000..9749afdc580 --- /dev/null +++ b/app/views/projects/blob/_auxiliary_viewer.html.haml @@ -0,0 +1,5 @@ +- blob = local_assigns.fetch(:blob) +- auxiliary_viewer = blob.auxiliary_viewer +- if auxiliary_viewer && auxiliary_viewer.render_error.nil? && auxiliary_viewer.visible_to?(current_user) + .well-segment.blob-auxiliary-viewer + = render 'projects/blob/viewer', viewer: auxiliary_viewer diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index f04df441ccb..8bd336269ff 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -1,23 +1,11 @@ -.nav-block - .tree-ref-holder - = render 'shared/ref_switcher', destination: 'blob', path: @path += render "projects/blob/breadcrumb", blob: blob - %ul.breadcrumb.repo-breadcrumb - %li - = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| - - title = truncate(title, length: 40) - %li - - if path == @path - = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do - %strong= title - - else - = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) +.info-well.hidden-xs + .well-segment + %ul.blob-commit-info + = render 'projects/commits/commit', commit: @last_commit, project: @project, ref: @ref -%ul.blob-commit-info.hidden-xs - - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path) - = render blob_commit, project: @project, ref: @ref + = render "projects/blob/auxiliary_viewer", blob: blob #blob-content-holder.blob-content-holder %article.file-holder diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml new file mode 100644 index 00000000000..0ad9f258e48 --- /dev/null +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -0,0 +1,36 @@ +- blame = local_assigns.fetch(:blame, false) +.nav-block + .tree-controls + = render 'projects/find_file_link' + + .btn-group.prepend-left-10{ role: "group" }< + -# only show normal/blame view links for text files + - if blob.readable_text? + - if blame + = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), + class: 'btn' + - else + = link_to 'Annotate', namespace_project_blame_path(@project.namespace, @project, @id), + class: 'btn js-blob-blame-link' unless blob.empty? + + = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), + class: 'btn' + + = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, + tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url' + + .tree-ref-holder + = render 'shared/ref_switcher', destination: 'blob', path: @path + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do + %strong= title + - else + = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index cd098acda81..0be15cc179f 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -11,23 +11,7 @@ = view_on_environment_button(@commit.sha, @path, @environment) if @environment .btn-group{ role: "group" }< - -# only show normal/blame view links for text files - - if blob.readable_text? - - if blame - = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), - class: 'btn btn-sm' - - else - = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), - class: 'btn btn-sm js-blob-blame-link' unless blob.empty? - - = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), - class: 'btn btn-sm' - - = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, - tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url' - - .btn-group{ role: "group" }< - = edit_blob_link if blob.readable_text? + = edit_blob_link - if current_user = replace_blob_link = delete_blob_link diff --git a/app/views/projects/blob/_markup.html.haml b/app/views/projects/blob/_markup.html.haml deleted file mode 100644 index 0090f7a11df..00000000000 --- a/app/views/projects/blob/_markup.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- blob.load_all_data!(@repository) - -.file-content.wiki - = markup(blob.name, blob.data) diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml index 7f470b890ba..40978583e8b 100644 --- a/app/views/projects/blob/_new_dir.html.haml +++ b/app/views/projects/blob/_new_dir.html.haml @@ -3,18 +3,18 @@ .modal-content .modal-header %a.close{ href: "#", "data-dismiss" => "modal" } × - %h3.page-title Create New Directory + %h3.page-title= _('Create New Directory') .modal-body = form_tag namespace_project_create_dir_path(@project.namespace, @project, @id), method: :post, remote: false, class: 'form-horizontal js-create-dir-form js-quick-submit js-requires-input' do .form-group - = label_tag :dir_name, 'Directory name', class: 'control-label' + = label_tag :dir_name, _('Directory name'), class: 'control-label' .col-sm-10 = text_field_tag :dir_name, params[:dir_name], required: true, class: 'form-control' - = render 'shared/new_commit_form', placeholder: "Add new directory" + = render 'shared/new_commit_form', placeholder: _("Add new directory") .form-actions - = submit_tag "Create directory", class: 'btn btn-create' + = submit_tag _("Create directory"), class: 'btn btn-create' = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal" - unless can?(current_user, :push_code, @project) diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml index 5326bb3e0cf..4252f27d007 100644 --- a/app/views/projects/blob/_viewer.html.haml +++ b/app/views/projects/blob/_viewer.html.haml @@ -1,12 +1,11 @@ - hidden = local_assigns.fetch(:hidden, false) - render_error = viewer.render_error -- load_asynchronously = local_assigns.fetch(:load_asynchronously, viewer.server_side?) && render_error.nil? +- load_async = local_assigns.fetch(:load_async, viewer.load_async?) -- url = url_for(params.merge(viewer: viewer.type, format: :json)) if load_asynchronously -.blob-viewer{ data: { type: viewer.type, url: url }, class: ('hidden' if hidden) } - - if load_asynchronously - .text-center.prepend-top-default.append-bottom-default - = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content') +- viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_async +.blob-viewer{ data: { type: viewer.type, url: viewer_url }, class: ('hidden' if hidden) } + - if load_async + = render viewer.loading_partial_path, viewer: viewer - elsif render_error = render 'projects/blob/render_error', viewer: viewer - else diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index e87b73c9a34..da2cef17e8a 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -1,4 +1,4 @@ -.diff-file +.diff-file.file-holder .diff-content - if markup?(@blob.name) .file-content.wiki diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 67f57b5e4b9..41f75a491a5 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -1,13 +1,14 @@ - @no_container = true + - page_title @blob.path, @ref = render "projects/commits/head" - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('blob') -%div{ class: container_class } - = render 'projects/last_push' += render 'projects/last_push' +%div{ class: container_class } #tree-holder.tree-holder = render 'blob', blob: @blob diff --git a/app/views/projects/blob/viewers/_changelog.html.haml b/app/views/projects/blob/viewers/_changelog.html.haml new file mode 100644 index 00000000000..53921e63b5f --- /dev/null +++ b/app/views/projects/blob/viewers/_changelog.html.haml @@ -0,0 +1,4 @@ += icon('history fw') += succeed '.' do + To find the state of this project's repository at the time of any of these versions, check out + = link_to "the tags", namespace_project_tags_path(viewer.project.namespace, viewer.project) diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml new file mode 100644 index 00000000000..c78f04c9c7c --- /dev/null +++ b/app/views/projects/blob/viewers/_contributing.html.haml @@ -0,0 +1,9 @@ += icon('book fw') +After you've reviewed these contribution guidelines, you'll be all set to + +- options = contribution_options(viewer.project) +- if options.any? + = succeed '.' do + = options.to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe +- else + contribute to this project. diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml new file mode 100644 index 00000000000..a0f0215a5ff --- /dev/null +++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml @@ -0,0 +1,11 @@ += icon('cubes fw') += succeed '.' do + This project manages its dependencies using + %strong= viewer.manager_name + + - if viewer.package_name + and defines a #{viewer.package_type} named + %strong< + = link_to viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer' + += link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml new file mode 100644 index 00000000000..28c5be6ebf3 --- /dev/null +++ b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml @@ -0,0 +1,9 @@ +- if viewer.valid? + = icon('check fw') + This GitLab CI configuration is valid. +- else + = icon('warning fw') + This GitLab CI configuration is invalid: + = viewer.validation_message + += link_to 'Learn more', help_page_path('ci/yaml/README') diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml new file mode 100644 index 00000000000..10cbf6a2f7a --- /dev/null +++ b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml @@ -0,0 +1,4 @@ += icon('spinner spin fw') +Validating GitLab CI configuration… + += link_to 'Learn more', help_page_path('ci/yaml/README') diff --git a/app/views/projects/blob/viewers/_license.html.haml b/app/views/projects/blob/viewers/_license.html.haml new file mode 100644 index 00000000000..fb9d0b99d09 --- /dev/null +++ b/app/views/projects/blob/viewers/_license.html.haml @@ -0,0 +1,8 @@ +- license = viewer.license + += icon('balance-scale fw') +This project is licensed under the += succeed '.' do + %strong= license.name + += link_to 'Learn more', license.url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml new file mode 100644 index 00000000000..120c0540335 --- /dev/null +++ b/app/views/projects/blob/viewers/_loading.html.haml @@ -0,0 +1,2 @@ +.text-center.prepend-top-default.append-bottom-default + = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…') diff --git a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml new file mode 100644 index 00000000000..c7dc9e3250a --- /dev/null +++ b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml @@ -0,0 +1,2 @@ += icon('spinner spin fw') +Analyzing file… diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml new file mode 100644 index 00000000000..334b33faf48 --- /dev/null +++ b/app/views/projects/blob/viewers/_readme.html.haml @@ -0,0 +1,4 @@ += icon('info-circle fw') += succeed '.' do + To learn more about this project, read + = link_to "the wiki", namespace_project_wikis_path(viewer.project.namespace, viewer.project) diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml new file mode 100644 index 00000000000..d0fcd55f6c1 --- /dev/null +++ b/app/views/projects/blob/viewers/_route_map.html.haml @@ -0,0 +1,9 @@ +- if viewer.valid? + = icon('check fw') + This Route Map is valid. +- else + = icon('warning fw') + This Route Map is invalid: + = viewer.validation_message + += link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map') diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml new file mode 100644 index 00000000000..2318cf82f58 --- /dev/null +++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml @@ -0,0 +1,4 @@ += icon('spinner spin fw') +Validating Route Map… + += link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map') diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index efec69662f3..6684ecfce81 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -26,6 +26,7 @@ ":disabled" => "disabled", ":issue-link-base" => "issueLinkBase", ":root-path" => "rootPath", + ":board-id" => "boardId", ":key" => "_uid" } = render "projects/boards/components/sidebar" %board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'), diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml index bc5c727bf0d..55c4d51be14 100644 --- a/app/views/projects/boards/components/_board.html.haml +++ b/app/views/projects/boards/components/_board.html.haml @@ -1,8 +1,11 @@ -.board{ ":class" => '{ "is-draggable": !list.preset }', +.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded }', ":data-id" => "list.id" } .board-inner - %header.board-header{ ":class" => '{ "has-border": list.label }', ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" } + %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" } %h3.board-title.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset) }' } + %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable", + ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded && list.position === -1, \"fa-caret-left\": !list.isExpanded && list.position !== -1 }", + "aria-hidden": "true" } %span.has-tooltip{ ":title" => '(list.label ? list.label.description : "")', data: { container: "body", placement: "bottom" } } {{ list.title }} @@ -10,13 +13,13 @@ %span.board-issue-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' } {{ list.issuesSize }} - if can?(current_user, :admin_issue, @project) - %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button", + %button.btn.btn-small.btn-default.pull-right.has-tooltip.js-no-trigger-collapse{ type: "button", "@click" => "showNewIssueForm", "v-if" => 'list.type !== "closed"', "aria-label" => "New issue", "title" => "New issue", data: { placement: "top", container: "body" } } - = icon("plus") + = icon("plus", class: "js-no-trigger-collapse") - if can?(current_user, :admin_list, @project) %board-delete{ "inline-template" => true, ":list" => "list", diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml index 48f8c656080..e8db868f49b 100644 --- a/app/views/projects/boards/components/sidebar/_assignee.html.haml +++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml @@ -14,7 +14,10 @@ name: "issue[assignee_ids][]", ":value" => "assignee.id", "v-if" => "issue.assignees", - "v-for" => "assignee in issue.assignees" } + "v-for" => "assignee in issue.assignees", + ":data-avatar_url" => "assignee.avatar", + ":data-name" => "assignee.name", + ":data-username" => "assignee.username" } .dropdown %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", 'max-select' => 1, dropdown: { header: 'Assignee' } }, ":data-issuable-id" => "issue.id", diff --git a/app/views/projects/boards/components/sidebar/_milestone.html.haml b/app/views/projects/boards/components/sidebar/_milestone.html.haml index 190e7290303..4e46351bf8a 100644 --- a/app/views/projects/boards/components/sidebar/_milestone.html.haml +++ b/app/views/projects/boards/components/sidebar/_milestone.html.haml @@ -16,7 +16,8 @@ name: "issue[milestone_id]", "v-if" => "issue.milestone" } .dropdown - %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: "issue", use_id: "true" }, + %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: "issue", use_id: "true", default_no: "true" }, + ":data-selected" => "milestoneTitle", ":data-issuable-id" => "issue.id", ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } Milestone diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 0f9ef3eded3..869633e016d 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -6,7 +6,8 @@ - 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), class: 'item-title str-truncated' do + = link_to namespace_project_tree_path(@project.namespace, @project, branch.name), class: 'item-title str-truncated ref-name' do + = icon('code-fork') = branch.name - if branch.name == @repository.root_ref @@ -30,16 +31,37 @@ = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[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 js-ajax-loading-spinner #{can_remove_branch?(@project, branch.name) ? '' : 'disabled'}", - method: :delete, - data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?" }, - remote: true, - "aria-label" => "Delete branch" do - = icon("trash-o") + - if branch.name == @project.repository.root_ref + %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled", + disabled: true, + title: "The default branch cannot be deleted" } + = icon("trash-o") + - elsif protected_branch?(@project, branch) + - if can?(current_user, :delete_protected_branch, @project) + %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip", + title: "Delete protected branch", + data: { toggle: "modal", + target: "#modal-delete-branch", + delete_path: namespace_project_branch_path(@project.namespace, @project, branch.name), + branch_name: branch.name } } + = icon("trash-o") + - else + %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled", + disabled: true, + title: "Only a project master or owner can delete a protected branch" } + = icon("trash-o") + - else + = link_to namespace_project_branch_path(@project.namespace, @project, branch.name), + class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip", + title: "Delete branch", + method: :delete, + data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?" }, + remote: true, + "aria-label" => "Delete branch" do + = icon("trash-o") - if branch.name != @repository.root_ref - .divergence-graph{ title: "#{number_commits_ahead} commits ahead, #{number_commits_behind} commits behind #{@repository.root_ref}" } + .divergence-graph{ title: "#{number_commits_behind} commits behind #{@repository.root_ref}, #{number_commits_ahead} commits ahead" } .graph-side .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" } %span.count.count-behind= number_commits_behind diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml index de607772df6..ad8f9da0621 100644 --- a/app/views/projects/branches/_commit.html.haml +++ b/app/views/projects/branches/_commit.html.haml @@ -1,7 +1,7 @@ .branch-commit .icon-container.commit-icon = custom_icon("icon_commit") - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-id monospace" + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-sha" · %span.str-truncated = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" diff --git a/app/views/projects/branches/_delete_protected_modal.html.haml b/app/views/projects/branches/_delete_protected_modal.html.haml new file mode 100644 index 00000000000..c5888afa54d --- /dev/null +++ b/app/views/projects/branches/_delete_protected_modal.html.haml @@ -0,0 +1,34 @@ +#modal-delete-branch.modal{ tabindex: -1 } + .modal-dialog + .modal-content + .modal-header + %button.close{ data: { dismiss: 'modal' } } × + %h3.page-title + Delete protected branch + = surround "'", "'?" do + %span.js-branch-name>[branch name] + + .modal-body + %p + You’re about to permanently delete the protected branch + = succeed '.' do + %strong.js-branch-name [branch name] + %p + Once you confirm and press + = succeed ',' do + %strong Delete protected branch + it cannot be undone or recovered. + %p + %strong To confirm, type + %kbd.js-branch-name [branch name] + + .form-group + = text_field_tag 'delete_branch_input', '', class: 'form-control js-delete-branch-input' + + .modal-footer + %button.btn{ data: { dismiss: 'modal' } } Cancel + = link_to 'Delete protected branch', '', + class: "btn btn-danger js-delete-branch", + title: 'Delete branch', + method: :delete, + "aria-label" => "Delete" diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 91b86280e4c..4bade77a077 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -37,3 +37,5 @@ = paginate @branches, theme: 'gitlab' - else .nothing-here-block No branches to show + += render 'projects/branches/delete_protected_modal' diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index 55575c5e412..fd2c9c3fa32 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -20,7 +20,11 @@ .col-sm-10.create-from .dropdown = hidden_field_tag :ref, default_ref +<<<<<<< HEAD = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do +======= + = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select git-revision-dropdown-toggle', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 .text-left.dropdown-toggle-text= default_ref = icon('chevron-down') = render 'shared/ref_dropdown', dropdown_class: 'wide' diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index d90d4a27cd6..3cf91bf07f7 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -2,29 +2,29 @@ - if !project.empty_repo? && can?(current_user, :download_code, project) .project-action-button.dropdown.inline> - %button.btn{ 'data-toggle' => 'dropdown' } + %button.btn.has-tooltip{ title: 'Download', 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download') } = icon('download') = icon("caret-down") - %span.sr-only - Select Archive Format + %span.sr-only= _('Select Archive Format') %ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' } - %li.dropdown-header Source code + %li.dropdown-header + #{ _('Source code') } %li = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do %i.fa.fa-download - %span Download zip + %span= _('Download zip') %li = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do %i.fa.fa-download - %span Download tar.gz + %span= _('Download tar.gz') %li = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do %i.fa.fa-download - %span Download tar.bz2 + %span= _('Download tar.bz2') %li = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do %i.fa.fa-download - %span Download tar + %span= _('Download tar') - if pipeline - artifacts = pipeline.builds.latest.with_artifacts @@ -39,4 +39,5 @@ %li = link_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do %i.fa.fa-download - %span Download '#{job.name}' + %span + #{ s_('DownloadArtifacts|Download') } '#{job.name}' diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 67de8699b2e..312c349da3a 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -1,6 +1,6 @@ - if current_user .project-action-button.dropdown.inline - %a.btn.dropdown-toggle{ href: '#', "data-toggle" => "dropdown" } + %a.btn.dropdown-toggle.has-tooltip{ href: '#', title: 'Create new...', 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => 'Create new...' } = icon('plus') = icon("caret-down") %ul.dropdown-menu.dropdown-menu-align-right.project-home-dropdown @@ -12,19 +12,19 @@ %li = link_to new_namespace_project_issue_path(@project.namespace, @project) do = icon('exclamation-circle fw') - New issue + #{ _('New issue') } - if merge_project %li = link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project) do = icon('tasks fw') - New merge request + #{ _('New merge request') } - if can_create_snippet %li = link_to new_namespace_project_snippet_path(@project.namespace, @project) do = icon('file-text-o fw') - New snippet + #{ _('New snippet') } - if can_create_issue || merge_project || can_create_snippet %li.divider @@ -33,20 +33,20 @@ %li = link_to namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master') do = icon('file fw') - New file + #{ _('New file') } %li = link_to new_namespace_project_branch_path(@project.namespace, @project) do = icon('code-fork fw') - New branch + #{ _('New branch') } %li = link_to new_namespace_project_tag_path(@project.namespace, @project) do = icon('tags fw') - New tag + #{ _('New tag') } - elsif current_user && current_user.already_forked?(@project) %li = link_to namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master') do = icon('file fw') - New file + #{ _('New file') } - elsif can?(current_user, :fork_project, @project) %li - continue_params = { to: namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master'), @@ -56,4 +56,4 @@ continue: continue_params) = link_to fork_path, method: :post do = icon('file fw') - New file + #{ _('New file') } diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index 851fe44a86d..7a08bb37494 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -1,14 +1,14 @@ - unless @project.empty_repo? - if current_user && can?(current_user, :fork_project, @project) - 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 + = 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') - %span Fork + %span= s_('GoToYourFork|Fork') - else - = link_to new_namespace_project_fork_path(@project.namespace, @project), title: 'Fork project', class: 'btn' do + = link_to new_namespace_project_fork_path(@project.namespace, @project), class: 'btn' do = custom_icon('icon_fork') - %span Fork + %span= s_('CreateNewFork|Fork') .count-with-arrow %span.arrow - = link_to namespace_project_forks_path(@project.namespace, @project), title: 'Forks', class: 'count' do + = link_to namespace_project_forks_path(@project.namespace, @project), title: n_('Forks', @project.forks_count), class: 'count' do = @project.forks_count diff --git a/app/views/projects/buttons/_koding.html.haml b/app/views/projects/buttons/_koding.html.haml index a5a9e4d0621..de2d61d4aa3 100644 --- a/app/views/projects/buttons/_koding.html.haml +++ b/app/views/projects/buttons/_koding.html.haml @@ -1,3 +1,3 @@ - if koding_enabled? && current_user && @repository.koding_yml && can_push_branch?(@project, @project.default_branch) = link_to koding_project_url(@project), class: 'btn project-action-button inline', target: '_blank', rel: 'noopener noreferrer' do - Run in IDE (Koding) + _('Run in IDE (Koding)') diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index d57eb2cbfbc..58413e2fc52 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -2,19 +2,19 @@ = link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star', method: :post, remote: true } do - if current_user.starred?(@project) = icon('star') - %span.starred Unstar + %span.starred= _('Unstar') - else = icon('star-o') - %span Star + %span= s_('StarProject|Star') .count-with-arrow %span.arrow %span.count.star-count = @project.star_count - else - = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: 'You must sign in to star a project' do + = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: _('You must sign in to star a project') do = icon('star') - Star + #{ s_('StarProject|Star') } .count-with-arrow %span.arrow %span.count diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 84ac03237e1..d9f28d66b66 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -14,7 +14,7 @@ %td.branch-commit - if can?(current_user, :read_build, job) - = link_to namespace_project_build_url(job.project.namespace, job.project, job) do + = link_to namespace_project_job_url(job.project.namespace, job.project, job) do %span.build-link ##{job.id} - else %span.build-link ##{job.id} @@ -23,14 +23,14 @@ - if job.ref .icon-container = job.tag? ? icon('tag') : icon('code-fork') - = link_to job.ref, namespace_project_commits_path(job.project.namespace, job.project, job.ref), class: "monospace branch-name" + = link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name" - else .light none .icon-container.commit-icon = custom_icon("icon_commit") - if commit_sha - = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-id monospace" + = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-sha" - if job.stuck? = icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.') @@ -58,7 +58,7 @@ - if pipeline.user = user_avatar(user: pipeline.user, size: 20) - else - %span.monospace API + %span.api API - if admin %td @@ -95,16 +95,16 @@ %td .pull-right - if can?(current_user, :read_build, job) && job.artifacts? - = link_to download_namespace_project_build_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do + = link_to download_namespace_project_job_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do = icon('download') - if can?(current_user, :update_build, job) - if job.active? - = link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do + = link_to cancel_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif allow_retry - if job.playable? && !admin && can?(current_user, :update_build, job) - = link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do + = link_to play_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do = custom_icon('icon_play') - elsif job.retryable? - = link_to retry_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do + = link_to retry_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do = icon('repeat') diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 64adb70cb81..aab50310234 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -1,6 +1,8 @@ .page-content-header .header-main-content - %strong Commit #{@commit.short_id} + %strong + Commit + %span.commit-sha= @commit.short_id = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard") %span.hidden-xs authored #{time_ago_with_tooltip(@commit.authored_date)} @@ -57,7 +59,7 @@ = custom_icon("icon_commit") %span.cgray= pluralize(@commit.parents.count, "parent") - @commit.parents.each do |parent| - = link_to parent.short_id, namespace_project_commit_path(@project.namespace, @project, parent), class: "monospace" + = link_to parent.short_id, namespace_project_commit_path(@project.namespace, @project, parent), class: "commit-sha" %span.commit-info.branches %i.fa.fa-spinner.fa-spin @@ -68,9 +70,10 @@ = link_to namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) do = ci_icon_for_status(last_pipeline.status) Pipeline - = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id), class: "monospace" + = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) = ci_label_for_status(last_pipeline.status) - - if last_pipeline.stages.any? + - if last_pipeline.stages_count.nonzero? + with #{"stage".pluralize(last_pipeline.stages_count)} .mr-widget-pipeline-graph = render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph' in diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml deleted file mode 100644 index 3ee85723ebe..00000000000 --- a/app/views/projects/commit/_pipeline.html.haml +++ /dev/null @@ -1,52 +0,0 @@ -.pipeline-graph-container - .row-content-block.build-content.middle-block.pipeline-actions - .pull-right - - if can?(current_user, :update_pipeline, pipeline.project) - - if pipeline.builds.latest.failed.any?(&:retryable?) - = link_to "Retry", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'js-retry-button 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 - - .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), "job" - - 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.js-pipeline-graph.hidden - = render "projects/pipelines/graph", pipeline: pipeline - -- if pipeline.yaml_errors.present? - .bs-callout.bs-callout-danger - %h4 Found errors in your .gitlab-ci.yml: - %ul - - pipeline.yaml_errors.split(",").each do |error| - %li= error - You can also test your .gitlab-ci.yml in the #{link_to "Lint", ci_lint_path} - -- if pipeline.project.builds_enabled? && !pipeline.ci_yaml_file - .bs-callout.bs-callout-warning - \.gitlab-ci.yml not found in this commit - -.table-holder.pipeline-holder - %table.table.ci-table.pipeline - %thead - %tr - %th Status - %th Job ID - %th Name - %th - %th Coverage - %th - = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage diff --git a/app/views/projects/commit/branches.html.haml b/app/views/projects/commit/branches.html.haml index 2b0c9a4b4de..911c9ddce06 100644 --- a/app/views/projects/commit/branches.html.haml +++ b/app/views/projects/commit/branches.html.haml @@ -1,15 +1,15 @@ -- if @branches.any? - %span - - branch = commit_default_branch(@project, @branches) - = link_to(namespace_project_tree_path(@project.namespace, @project, branch)) do - %span.label.label-gray - = branch - - if @branches.any? || @tags.any? - = link_to("#", class: "js-details-expand") do - %span.label.label-gray - \... +- if @branches.any? || @tags.any? + - branch = commit_default_branch(@project, @branches) + = link_to(project_ref_path(@project, branch), class: "label label-gray ref-name") do + = icon('code-fork') + = branch + + -# `commit_default_branch` deletes the default branch from `@branches`, + -# so only render this if we have more branches left + - if @branches.any? || @tags.any? + %span + = link_to "…", "#", class: "js-details-expand label label-gray" + %span.js-details-content.hide - - if @branches.any? - = commit_branches_links(@project, @branches) - - if @tags.any? - = commit_tags_links(@project, @tags) + = commit_branches_links(@project, @branches) if @branches.any? + = commit_tags_links(@project, @tags) if @tags.any? diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 6051ea2f1ce..3a1be3fa4b6 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -13,7 +13,7 @@ .block-connector = render "projects/diffs/diffs", diffs: @diffs, environment: @environment - = render "shared/notes/notes_with_form" + = render "shared/notes/notes_with_form", :autocomplete => true - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| = render "projects/commit/change", type: type, commit: @commit, title: @commit.title diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 8f32d2b72e5..7a03c3561af 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -31,12 +31,12 @@ = preserve(markdown(commit.description, pipeline: :single_line, author: commit.author)) .commiter = commit_author_link(commit, avatar: false, size: 24) - committed + #{ _('committed') } #{time_ago_with_tooltip(commit.committed_date)} .commit-actions.flex-row.hidden-xs - if commit.status(ref) = render_commit_status(commit, ref: ref) - = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard") - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha btn btn-transparent" + = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard")) = link_to_browse_code(project, commit) diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 88c7d7bc44b..d3380c917e4 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -2,8 +2,11 @@ - commits, hidden = limited_commits(@commits) - commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits| - %li.commit-header #{day.strftime('%d %b, %Y')} #{pluralize(commits.count, 'commit')} - %li.commits-row + %li.commit-header.js-commit-header{ data: { day: day } } + %span.day= day.strftime('%d %b, %Y') + %span.commits-count= pluralize(commits.count, 'commit') + + %li.commits-row{ data: { day: day } } %ul.content-list.commit-list = render commits, project: project, ref: ref diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml index dd6797f10c0..ebeaab863bc 100644 --- a/app/views/projects/commits/_head.html.haml +++ b/app/views/projects/commits/_head.html.haml @@ -5,32 +5,32 @@ %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 + #{ _('Files') } = nav_link(controller: [:commit, :commits]) do = link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do - Commits + #{ _('Commits') } = nav_link(html_options: {class: branches_tab_class}) do = link_to namespace_project_branches_path(@project.namespace, @project) do - Branches + #{ _('Branches') } = nav_link(controller: [:tags, :releases]) do = link_to namespace_project_tags_path(@project.namespace, @project) do - Tags + #{ _('Tags') } = nav_link(path: 'graphs#show') do = link_to namespace_project_graph_path(@project.namespace, @project, current_ref) do - Contributors + #{ _('Contributors') } = nav_link(controller: %w(network)) do = link_to namespace_project_network_path(@project.namespace, @project, current_ref) do - Graph + #{ s_('ProjectNetworkGraph|Graph') } = nav_link(controller: :compare) do = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: current_ref) do - Compare + #{ _('Compare') } = nav_link(path: 'graphs#charts') do = link_to charts_namespace_project_graph_path(@project.namespace, @project, current_ref) do - Charts + #{ _('Charts') } diff --git a/app/views/projects/commits/_inline_commit.html.haml b/app/views/projects/commits/_inline_commit.html.haml index c03bc3f9df9..5fb89935467 100644 --- a/app/views/projects/commits/_inline_commit.html.haml +++ b/app/views/projects/commits/_inline_commit.html.haml @@ -1,6 +1,6 @@ %li.commit.inline-commit .commit-row-title - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha" %span.str-truncated = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index 1f4c9fac54c..adb724c1b8d 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -7,7 +7,7 @@ .input-group.inline-input-group %span.input-group-addon from = hidden_field_tag :from, params[:from] - = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", 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 + = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip git-revision-dropdown-toggle", 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.str-truncated= params[:from] || 'Select branch/tag' = render 'shared/ref_dropdown' .compare-ellipsis.inline ... @@ -15,7 +15,7 @@ .input-group.inline-input-group %span.input-group-addon to = hidden_field_tag :to, params[:to] - = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", 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 + = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip git-revision-dropdown-toggle", 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.str-truncated= params[:to] || 'Select branch/tag' = render 'shared/ref_dropdown' diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index 45be6581cfc..2cf14859f30 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -6,10 +6,10 @@ .sub-header-block Compare Git revisions. %br - Fill input field with commit id like - %code.label-branch 4eedf23 + Fill input field with commit SHA like + %code.ref-name 4eedf23 or branch/tag name like - %code.label-branch master + %code.ref-name master and press compare button for the commits list and a code diff. %br Changes are shown <b>from</b> the version in the first field <b>to</b> the version in the second field. diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index 0dfc9fe20ed..a1bca2cf83a 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -16,9 +16,9 @@ There isn't anything to compare. %p.slead - if params[:to] == params[:from] - %span.label-branch= params[:from] + %span.ref-name= params[:from] and - %span.label-branch= params[:to] + %span.ref-name= params[:to] are the same. - else You'll need to use different branch names to get a valid comparison. diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 74255167352..7000b289f75 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -2,7 +2,6 @@ - page_title "Cycle Analytics" - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('locale') = page_specific_javascript_bundle_tag('cycle_analytics') = render "projects/head" diff --git a/app/views/projects/deploy_keys/_deploy_key.html.haml b/app/views/projects/deploy_keys/_deploy_key.html.haml deleted file mode 100644 index ec8fc4c9ee8..00000000000 --- a/app/views/projects/deploy_keys/_deploy_key.html.haml +++ /dev/null @@ -1,30 +0,0 @@ -%li - .pull-left.append-right-10.hidden-xs - = icon "key", class: "key-icon" - .deploy-key-content.key-list-item-info - %strong.title - = deploy_key.title - .description - = deploy_key.fingerprint - - if deploy_key.can_push? - .write-access-allowed - Write access allowed - .deploy-key-content.prepend-left-default.deploy-key-projects - - deploy_key.projects.each do |project| - - if can?(current_user, :read_project, project) - = link_to namespace_project_path(project.namespace, project), class: "label deploy-project-label" do - = project.name_with_namespace - .deploy-key-content - %span.key-created-at - created #{time_ago_with_tooltip(deploy_key.created_at)} - .visible-xs-block.visible-sm-block - - if @deploy_keys.key_available?(deploy_key) - = link_to enable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: "btn btn-sm prepend-left-10", method: :put do - Enable - - else - - if deploy_key.destroyed_when_orphaned? && deploy_key.almost_orphaned? - = link_to disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), data: { confirm: "You are going to remove deploy key. Are you sure?" }, method: :put, class: "btn btn-warning btn-sm prepend-left-10" do - Remove - - else - = link_to disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: "btn btn-warning btn-sm prepend-left-10", method: :put do - Disable diff --git a/app/views/projects/deploy_keys/_form.html.haml b/app/views/projects/deploy_keys/_form.html.haml index 1421da72418..edaa3a1119e 100644 --- a/app/views/projects/deploy_keys/_form.html.haml +++ b/app/views/projects/deploy_keys/_form.html.haml @@ -2,7 +2,7 @@ = form_errors(@deploy_keys.new_key) .form-group = f.label :title, class: "label-light" - = f.text_field :title, class: 'form-control', autofocus: true, required: true + = f.text_field :title, class: 'form-control', required: true .form-group = f.label :key, class: "label-light" = f.text_area :key, class: "form-control", rows: 5, required: true diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 74756b58439..6e038ffd9c0 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -1,13 +1,15 @@ -.row.prepend-top-default - .col-lg-3.profile-settings-sidebar - %h4.prepend-top-0 +- expanded = Rails.env.test? +%section.settings + .settings-header + %h4 Deploy Keys + %button.btn.js-settings-toggle + = expanded ? 'Close' : 'Expand' %p Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one. - .col-lg-9 + .settings-content.no-animate{ class: ('expanded' if expanded) } %h5.prepend-top-0 Create a new deploy key for this project = render @deploy_keys.form_partial_path - .col-lg-9.col-lg-offset-3 %hr - #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } } + #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } } diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml new file mode 100644 index 00000000000..37219f8d7ae --- /dev/null +++ b/app/views/projects/deploy_keys/edit.html.haml @@ -0,0 +1,10 @@ +- page_title 'Edit Deploy Key' +%h3.page-title Edit Deploy Key +%hr + +%div + = form_for [@project.namespace.becomes(Namespace), @project, @deploy_key], html: { class: 'form-horizontal js-requires-input' } do |f| + = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } + .form-actions + = f.submit 'Save changes', class: 'btn-save btn' + = link_to 'Cancel', namespace_project_settings_repository_path(@project.namespace, @project), class: 'btn btn-cancel' diff --git a/app/views/projects/deploy_keys/new.html.haml b/app/views/projects/deploy_keys/new.html.haml deleted file mode 100644 index 01fab3008a7..00000000000 --- a/app/views/projects/deploy_keys/new.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -- page_title "New Deploy Key" -%h3.page-title New Deploy Key -%hr - -= render 'form' diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml index 506246f2ee6..e2baaa625ae 100644 --- a/app/views/projects/deployments/_actions.haml +++ b/app/views/projects/deployments/_actions.haml @@ -8,6 +8,7 @@ = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right - actions.each do |action| + - next unless can?(current_user, :update_build, action) %li = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do = custom_icon('icon_play') diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml index 170d786ecbf..4502c397d29 100644 --- a/app/views/projects/deployments/_commit.html.haml +++ b/app/views/projects/deployments/_commit.html.haml @@ -1,16 +1,17 @@ -.branch-commit - - if deployment.ref - .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.commit-icon - = custom_icon("icon_commit") - = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" +.table-mobile-content + .branch-commit + - if deployment.ref + %span.icon-container + = deployment.tag? ? icon('tag') : icon('code-fork') + = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name" + .icon-container.commit-icon + = custom_icon("icon_commit") + = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-sha" - %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 + %p.commit-title.flex-truncate-parent + %span.flex-truncate-child + - 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 260c9023daf..d956cb2cc1a 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -1,22 +1,26 @@ -%tr.deployment - %td - %strong ##{deployment.iid} +.gl-responsive-table-row.deployment{ role: 'row' } + .table-section.section-10{ role: 'gridcell' } + .table-mobile-header{ role: 'rowheader' } ID + %strong.table-mobile-content ##{deployment.iid} - %td + .table-section.section-40{ role: 'gridcell' } + .table-mobile-header{ role: 'rowheader' } Commit = render 'projects/deployments/commit', deployment: deployment - %td.build-column + .table-section.section-15.build-column{ role: 'gridcell' } + .table-mobile-header{ role: 'rowheader' } Job - if deployment.deployable - = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do + = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link table-mobile-content' 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)} + .table-section.section-15{ role: 'gridcell' } + .table-mobile-header{ role: 'rowheader' } Created + %span.table-mobile-content= time_ago_with_tooltip(deployment.created_at) - %td.hidden-xs - .pull-right.btn-group + .table-section.section-20.environments-actions.table-button-footer{ role: 'gridcell' } + .btn-group.environment-action-buttons = render 'projects/deployments/actions', deployment: deployment = render 'projects/deployments/rollback', deployment: deployment diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index c781e423c4d..59844bc00cd 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -1,12 +1,12 @@ -.diff-content.diff-wrap-lines - -# Skip all non non-supported blobs - - return unless blob.respond_to?(:text?) +- blob = diff_file.blob + +.diff-content - if diff_file.too_large? .nothing-here-block This diff could not be displayed because it is too large. - - elsif blob.too_large? + - elsif blob.truncated? .nothing-here-block The file could not be displayed because it is too large. - elsif blob.readable_text? - - if !project.repository.diffable?(blob) + - if !diff_file.repository.diffable?(blob) .nothing-here-block This diff was suppressed by a .gitattributes entry. - 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, file_identifier: diff_file.file_identifier)) @@ -15,20 +15,13 @@ %a.click-to-expand Click to expand it. - elsif diff_file.diff_lines.length > 0 - - total_lines = 0 - - if blob.lines.any? - - total_lines = blob.lines.last.chomp == '' ? blob.lines.size - 1 : blob.lines.size - - if diff_view == :parallel - = render "projects/diffs/parallel_view", diff_file: diff_file, total_lines: total_lines - - else - = render "projects/diffs/text_file", diff_file: diff_file, total_lines: total_lines + = render "projects/diffs/viewers/text", diff_file: diff_file - else - if diff_file.mode_changed? .nothing-here-block File mode changed - - elsif diff_file.renamed_file + - elsif diff_file.renamed_file? .nothing-here-block File moved - elsif blob.image? - - old_blob = diff_file.old_blob(diff_file.old_content_commit || @base_commit) - = render "projects/diffs/image", diff_file: diff_file, old_file: old_blob, file: blob + = render "projects/diffs/viewers/image", diff_file: diff_file - else .nothing-here-block No preview for this file type diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 71a1b9e6c05..d538c4c86c8 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -5,8 +5,8 @@ .content-block.oneline-block.files-changed .inline-parallel-buttons - - if !expand_all_diffs? && diff_files.any? { |diff_file| diff_file.collapsed? } - = link_to 'Expand all', url_for(params.merge(expand_all_diffs: 1, format: nil)), class: 'btn btn-default' + - if !diffs_expanded? && diff_files.any? { |diff_file| diff_file.collapsed? } + = link_to 'Expand all', url_for(params.merge(expanded: 1, format: nil)), class: 'btn btn-default' - if show_whitespace_toggle - if current_controller?(:commit) = commit_diff_whitespace_link(diffs.project, @commit, class: 'hidden-xs') @@ -23,12 +23,4 @@ = render 'projects/diffs/warning', diff_files: diffs .files{ data: { can_create_note: can_create_note } } - - diff_files.each_with_index do |diff_file| - - diff_commit = commit_for_diff(diff_file) - - blob = diff_file.blob(diff_commit) - - next unless blob - - blob.load_all_data!(diffs.project.repository) unless blob.too_large? - - file_hash = hexdigest(diff_file.file_path) - - = render 'projects/diffs/file', file_hash: file_hash, project: diffs.project, - diff_file: diff_file, diff_commit: diff_commit, blob: blob, environment: environment + = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment } diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index f22b385fc0f..b5aea217384 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -1,10 +1,12 @@ - environment = local_assigns.fetch(:environment, nil) -.diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_commit.id) } +- file_hash = hexdigest(diff_file.file_path) +.diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_file.content_sha) } .js-file-title.file-title-flex-parent .file-header-content - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "##{file_hash}" + = render "projects/diffs/file_header", diff_file: diff_file, url: "##{file_hash}" - unless diff_file.submodule? + - blob = diff_file.blob .file-actions.hidden-xs - if blob.readable_text? = link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: "Toggle comments for this file", disabled: @diff_notes_disabled do @@ -15,9 +17,9 @@ = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path, blob: blob, link_opts: link_opts) - = view_file_button(diff_commit.id, diff_file.new_path, project) - = view_on_environment_button(diff_commit.id, diff_file.new_path, environment) if environment + = view_file_button(diff_file.content_sha, diff_file.file_path, project) + = view_on_environment_button(diff_file.content_sha, diff_file.file_path, environment) if environment = render 'projects/fork_suggestion' - = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, blob: blob, project: project + = render 'projects/diffs/content', diff_file: diff_file diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index 7d6b3701f95..73c316472e3 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -1,17 +1,22 @@ -%i.fa.diff-toggle-caret.fa-fw -- if defined?(blob) && blob && diff_file.submodule? +- show_toggle = local_assigns.fetch(:show_toggle, true) + +- if show_toggle + %i.fa.diff-toggle-caret.fa-fw + +- if diff_file.submodule? + - blob = diff_file.blob %span = icon('archive fw') %strong.file-title-name - = submodule_link(blob, diff_commit.id, project.repository) + = submodule_link(blob, diff_file.content_sha, diff_file.repository) = copy_file_path_button(blob.path) - else = conditional_link_to url.present?, url do = blob_icon diff_file.b_mode, diff_file.file_path - - if diff_file.renamed_file + - if diff_file.renamed_file? - old_path, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) %strong.file-title-name.has-tooltip{ data: { title: diff_file.old_path, container: 'body' } } = old_path @@ -19,12 +24,13 @@ %strong.file-title-name.has-tooltip{ data: { title: diff_file.new_path, container: 'body' } } = new_path - else - %strong.file-title-name.has-tooltip{ data: { title: diff_file.new_path, container: 'body' } } - = diff_file.new_path - - if diff_file.deleted_file + %strong.file-title-name.has-tooltip{ data: { title: diff_file.file_path, container: 'body' } } + = diff_file.file_path + + - if diff_file.deleted_file? deleted - = copy_file_path_button(diff_file.new_path) + = copy_file_path_button(diff_file.file_path) - if diff_file.mode_changed? %small diff --git a/app/views/projects/diffs/_image.html.haml b/app/views/projects/diffs/_image.html.haml deleted file mode 100644 index ca10921c5e2..00000000000 --- a/app/views/projects/diffs/_image.html.haml +++ /dev/null @@ -1,69 +0,0 @@ -- diff = diff_file.diff -- file_raw_path = namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.new_ref, diff.new_path)) -// diff_refs will be nil for orphaned commits (e.g. first commit in repo) -- if diff_file.old_ref - - old_file_raw_path = namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.old_ref, diff.old_path)) - -- if diff.renamed_file || diff.new_file || diff.deleted_file - .image - %span.wrap - .frame{ class: image_diff_class(diff) } - %img{ src: diff.deleted_file ? old_file_raw_path : file_raw_path, alt: diff.new_path } - %p.image-info= number_to_human_size(file.size) -- else - .image - .two-up.view - %span.wrap - .frame.deleted - %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.old_ref, diff.old_path)) } - %img{ src: old_file_raw_path, alt: diff.old_path } - %p.image-info.hide - %span.meta-filesize= number_to_human_size(old_file.size) - | - %b W: - %span.meta-width - | - %b H: - %span.meta-height - %span.wrap - .frame.added - %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.new_ref, diff.new_path)) } - %img{ src: file_raw_path, alt: diff.new_path } - %p.image-info.hide - %span.meta-filesize= number_to_human_size(file.size) - | - %b W: - %span.meta-width - | - %b H: - %span.meta-height - - .swipe.view.hide - .swipe-frame - .frame.deleted - %img{ src: old_file_raw_path, alt: diff.old_path } - .swipe-wrap - .frame.added - %img{ src: file_raw_path, alt: diff.new_path } - %span.swipe-bar - %span.top-handle - %span.bottom-handle - - .onion-skin.view.hide - .onion-skin-frame - .frame.deleted - %img{ src: old_file_raw_path, alt: diff.old_path } - .frame.added - %img{ src: file_raw_path, alt: diff.new_path } - .controls - .transparent - .drag-track - .dragger{ :style => "left: 0px;" } - .opaque - - - .view-modes.hide - %ul.view-modes-menu - %li.two-up{ data: { mode: 'two-up' } } 2-up - %li.swipe{ data: { mode: 'swipe' } } Swipe - %li.onion-skin{ data: { mode: 'onion-skin' } } Onion skin diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 7439b8a66f7..43708d22a0c 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -3,7 +3,7 @@ - discussions = local_assigns.fetch(:discussions, nil) - type = line.type - line_code = diff_file.line_code(line) -- if discussions && !line.meta? +- if discussions && line.discussable? - line_discussions = discussions[line_code] %tr.line_holder{ class: type, id: (line_code unless plain) } - case type diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index 45c95f7ab6a..8e5f4d2573d 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -49,7 +49,7 @@ - if discussions_left || discussions_right = render "discussions/parallel_diff_discussion", discussions_left: discussions_left, discussions_right: discussions_right - - if !diff_file.new_file && !diff_file.deleted_file && diff_file.diff_lines.any? + - if !diff_file.new_file? && !diff_file.deleted_file? && diff_file.diff_lines.any? - last_line = diff_file.diff_lines.last - if last_line.new_pos < total_lines %tr.line_holder.parallel diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml index fd4f3c8d3cc..e69c7f20d49 100644 --- a/app/views/projects/diffs/_stats.html.haml +++ b/app/views/projects/diffs/_stats.html.haml @@ -12,19 +12,19 @@ - diff_files.each do |diff_file| - file_hash = hexdigest(diff_file.file_path) %li - - if diff_file.deleted_file + - if diff_file.deleted_file? %span.deleted-file %a{ href: "##{file_hash}" } %i.fa.fa-minus = diff_file.old_path - - elsif diff_file.renamed_file + - elsif diff_file.renamed_file? %span.renamed-file %a{ href: "##{file_hash}" } %i.fa.fa-minus = diff_file.old_path → = diff_file.new_path - - elsif diff_file.new_file + - elsif diff_file.new_file? %span.new-file %a{ href: "##{file_hash}" } %i.fa.fa-plus diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index 5f3968b6709..e8a5e63e59e 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -3,13 +3,13 @@ .suppressed-container %a.show-suppressed-diff.js-show-suppressed-diff Changes suppressed. Click to show. -%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' } +%table.text-file.diff-wrap-lines.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' } = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, discussions: @grouped_diff_discussions } - - if !diff_file.new_file && !diff_file.deleted_file && diff_file.highlighted_diff_lines.any? + - if !diff_file.new_file? && !diff_file.deleted_file? && diff_file.highlighted_diff_lines.any? - last_line = diff_file.highlighted_diff_lines.last - if last_line.new_pos < total_lines %tr.line_holder diff --git a/app/views/projects/diffs/viewers/_image.html.haml b/app/views/projects/diffs/viewers/_image.html.haml new file mode 100644 index 00000000000..ea75373581e --- /dev/null +++ b/app/views/projects/diffs/viewers/_image.html.haml @@ -0,0 +1,68 @@ +- blob = diff_file.blob +- old_blob = diff_file.old_blob +- blob_raw_path = diff_file_blob_raw_path(diff_file) +- old_blob_raw_path = diff_file_old_blob_raw_path(diff_file) + +- if diff_file.new_file? || diff_file.deleted_file? + .image + %span.wrap + .frame{ class: (diff_file.deleted_file? ? 'deleted' : 'added') } + %img{ src: blob_raw_path, alt: diff_file.file_path } + %p.image-info= number_to_human_size(blob.size) +- else + .image + .two-up.view + %span.wrap + .frame.deleted + %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.old_content_sha, diff_file.old_path)) } + %img{ src: old_blob_raw_path, alt: diff_file.old_path } + %p.image-info.hide + %span.meta-filesize= number_to_human_size(old_blob.size) + | + %b W: + %span.meta-width + | + %b H: + %span.meta-height + %span.wrap + .frame.added + %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.content_sha, diff_file.new_path)) } + %img{ src: blob_raw_path, alt: diff_file.new_path } + %p.image-info.hide + %span.meta-filesize= number_to_human_size(blob.size) + | + %b W: + %span.meta-width + | + %b H: + %span.meta-height + + .swipe.view.hide + .swipe-frame + .frame.deleted + %img{ src: old_blob_raw_path, alt: diff_file.old_path } + .swipe-wrap + .frame.added + %img{ src: blob_raw_path, alt: diff_file.new_path } + %span.swipe-bar + %span.top-handle + %span.bottom-handle + + .onion-skin.view.hide + .onion-skin-frame + .frame.deleted + %img{ src: old_blob_raw_path, alt: diff_file.old_path } + .frame.added + %img{ src: blob_raw_path, alt: diff_file.new_path } + .controls + .transparent + .drag-track + .dragger{ :style => "left: 0px;" } + .opaque + + + .view-modes.hide + %ul.view-modes-menu + %li.two-up{ data: { mode: 'two-up' } } 2-up + %li.swipe{ data: { mode: 'swipe' } } Swipe + %li.onion-skin{ data: { mode: 'onion-skin' } } Onion skin diff --git a/app/views/projects/diffs/viewers/_text.html.haml b/app/views/projects/diffs/viewers/_text.html.haml new file mode 100644 index 00000000000..e4b89671724 --- /dev/null +++ b/app/views/projects/diffs/viewers/_text.html.haml @@ -0,0 +1,8 @@ +- blob = diff_file.blob +- blob.load_all_data!(diff_file.repository) +- total_lines = blob.lines.size +- total_lines -= 1 if total_lines > 0 && blob.lines.last.blank? +- if diff_view == :parallel + = render "projects/diffs/parallel_view", diff_file: diff_file, total_lines: total_lines +- else + = render "projects/diffs/text_file", diff_file: diff_file, total_lines: total_lines diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 160345cfaa5..c3dab68cea5 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -40,9 +40,9 @@ .form_group.prepend-top-20.sharing-and-permissions .row.js-visibility-select .col-md-9 - %label.label-light - = label_tag :project_visibility, 'Project Visibility', class: 'label-light' - = link_to "(?)", help_page_path("public_access/public_access") + .label-light + = label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level + = link_to icon('question-circle'), help_page_path("public_access/public_access") %span.help-block .col-md-3.visibility-select-container = render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level) @@ -65,7 +65,7 @@ .row .col-md-9.project-feature.nested = feature_fields.label :builds_access_level, "Pipelines", class: 'label-light' - %span.help-block Submit, test and deploy your changes before merge + %span.help-block Build, test, and deploy your changes .col-md-3 = project_feature_access_select(:builds_access_level) @@ -92,14 +92,14 @@ .form-group = render 'shared/allow_request_access', form: f - if Gitlab.config.lfs.enabled && current_user.admin? - .row + .row.js-lfs-enabled .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' } + = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control project-repo-select', data: { field: 'lfs_enabled' } - if Gitlab.config.registry.enabled @@ -246,14 +246,16 @@ .row.prepend-top-default .col-lg-3 %h4.prepend-top-0.danger-title - Transfer project + Transfer project to new group + %p.append-bottom-0 + Please select the group you want to transfer this project to in the dropdown to the right. .col-lg-9 - = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_namespace_project_path(@project.namespace, @project), method: :put, remote: true) do |f| + = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_namespace_project_path(@project.namespace, @project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f| .form-group = label_tag :new_namespace_id, nil, class: 'label-light' do - %span Namespace + %span Select a new namespace .form-group - = select_tag :new_namespace_id, namespaces_options(@project.namespace_id), { prompt: 'Choose a project namespace', class: 'select2' } + = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2' %ul %li Be careful. Changing the project's namespace can have unintended side effects. %li You can only transfer the project to namespaces you manage. diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 7315e671056..23aa4c29e69 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -3,7 +3,7 @@ = render "projects/pipelines/head" %div{ class: container_class } - .top-area.adjust + .row.top-area.adjust .col-md-7 %h3.page-title= @environment.name .col-md-5 @@ -13,7 +13,7 @@ = render 'projects/environments/metrics_button', environment: @environment - if can?(current_user, :update_environment, @environment) = link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn' - - if can?(current_user, :create_deployment, @environment) && @environment.can_stop? + - if can?(current_user, :stop_environment, @environment) = link_to 'Stop', stop_namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to stop this environment?' }, class: 'btn btn-danger', method: :post .environments-container @@ -28,14 +28,12 @@ = link_to "Read more", help_page_path("ci/environments"), class: "btn btn-success" - else .table-holder - %table.table.ci-table.environments - %thead - %tr - %th ID - %th Commit - %th Job - %th Created - %th.hidden-xs + .ci-table.environments{ role: 'grid' } + .gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-10{ role: 'columnheader' } ID + .table-section.section-40{ role: 'columnheader' } Commit + .table-section.section-15{ role: 'columnheader' } Job + .table-section.section-15{ role: 'columnheader' } Created = render @deployments diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 4cdb44325b3..8a409541fe5 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,4 +1,5 @@ - page_title "Find File", @ref += render "projects/commits/head" .file-finder-holder.tree-holder.clearfix .nav-block @@ -9,7 +10,7 @@ = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do = @project.path %li.file-finder - %input#file_find.form-control.file-finder-input{ type: "text", placeholder: 'Find by path', autocomplete: 'off' } + %input#file_find.form-control.file-finder-input{ type: "text", placeholder: _('Find by path'), autocomplete: 'off' } .tree-content-holder .table-holder 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 f458646522c..b23bbadbdb4 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 @@ -27,7 +27,7 @@ = custom_icon("icon_commit") - if commit_sha - = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "commit-id monospace" + = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "commit-sha" - if retried = icon('warning', class: 'text-warning has-tooltip', title: 'Status was retried.') @@ -48,7 +48,7 @@ - if generic_commit_status.pipeline.user = user_avatar(user: generic_commit_status.pipeline.user, size: 20) - else - %span.monospace API + %span.api API - if admin %td diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml new file mode 100644 index 00000000000..6962b223451 --- /dev/null +++ b/app/views/projects/hook_logs/_index.html.haml @@ -0,0 +1,37 @@ +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Recent Deliveries + %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong. + .col-lg-9 + - if hook_logs.any? + %table.table + %thead + %tr + %th Status + %th Trigger + %th URL + %th Elapsed time + %th Request time + %th + - hook_logs.each do |hook_log| + %tr + %td + = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } + %td.hidden-xs + %span.label.label-gray.deploy-project-label + = hook_log.trigger.singularize.titleize + %td + = truncate(hook_log.url, length: 50) + %td.light + #{number_with_precision(hook_log.execution_duration, precision: 2)} ms + %td.light + = time_ago_with_tooltip(hook_log.created_at) + %td + = link_to 'View details', namespace_project_hook_hook_log_path(project.namespace, project, hook, hook_log) + + = paginate hook_logs, theme: 'gitlab' + + - else + .settings-message.text-center + You don't have any webhooks deliveries diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml new file mode 100644 index 00000000000..2eabe92f8eb --- /dev/null +++ b/app/views/projects/hook_logs/show.html.haml @@ -0,0 +1,11 @@ += render 'projects/settings/head' + +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Request details + .col-lg-9 + + = link_to 'Resend Request', retry_namespace_project_hook_hook_log_path(@project.namespace, @project, @hook, @hook_log), class: "btn btn-default pull-right prepend-left-10" + + = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml index 7998713be1f..fd382c1d63f 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -1,3 +1,4 @@ +- page_title 'Integrations' = render 'projects/settings/head' .row.prepend-top-default @@ -10,5 +11,12 @@ .col-lg-9.append-bottom-default = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: namespace_project_hook_path do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } + = f.submit 'Save changes', class: 'btn btn-create' + = link_to 'Test hook', test_namespace_project_hook_path(@project.namespace, @project, @hook), class: 'btn btn-default' + = link_to 'Remove', namespace_project_hook_path(@project.namespace, @project, @hook), method: :delete, class: 'btn btn-remove pull-right', data: { confirm: 'Are you sure?' } + +%hr + += render partial: 'projects/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs, project: @project } diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 4dfda54feb5..8b095f4ca10 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,7 +1,7 @@ - content_for :note_actions do - if can?(current_user, :update_issue, @issue) - = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {no_turbolink: true, original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' #notes - = render 'shared/notes/notes_with_form' + = render 'shared/notes/notes_with_form', :autocomplete => true diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index c184e0e0022..9e4e6934ca9 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 } } .issue-box - - if @bulk_edit - .issue-check + - if @can_bulk_update + .issue-check.hidden = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected_issue" .issue-info-container .issue-title.title diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 1892ebb512f..8c9f6f3b4df 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -11,5 +11,4 @@ = render_pipeline_status(pipeline) %span.related-branch-info %strong - = link_to namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "label-branch" do - = branch + = link_to branch, namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "ref-name" diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 60900e9d660..7183794ce72 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- @bulk_edit = can?(current_user, :admin_issue, @project) +- @can_bulk_update = can?(current_user, :admin_issue, @project) - page_title "Issues" - new_issue_email = @project.new_issue_address(current_user) @@ -20,6 +20,8 @@ .nav-controls = link_to params.merge(rss_url_options), class: 'btn append-right-10 has-tooltip', title: 'Subscribe' do = icon('rss') + - if @can_bulk_update + = button_tag "Edit Issues", class: "btn btn-default js-bulk-update-toggle" = link_to new_namespace_project_issue_path(@project.namespace, @project, issue: { assignee_id: issues_finder.assignee.try(:id), @@ -30,6 +32,9 @@ New issue = render 'shared/issuable/search_bar', type: :issues + - if @can_bulk_update + = render 'shared/issuable/bulk_update_sidebar', type: :issues + .issues-holder = render 'issues' - if new_issue_email diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 100f430d8a2..c70e8c33afe 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -2,6 +2,15 @@ - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_description @issue.description - page_card_attributes @issue.card_attributes +- can_update_issue = can?(current_user, :update_issue, @issue) +- can_report_spam = @issue.submittable_as_spam_by?(current_user) + +- if defined?(@issue) && @issue.confidential? + .confidential-issue-warning{ data: { spy: 'affix' } } + %span.confidential-issue-text + #{confidential_icon(@issue)} This issue is confidential. + %a{ href: help_page_path('user/project/issues/confidential_issues'), target: '_blank' } + What are confidential issues? .clearfix.detail-page-header .issuable-header @@ -17,7 +26,6 @@ = icon('angle-double-left') .issuable-meta - = confidential_icon(@issue) = issuable_meta(@issue, @project, "Issue") .issuable-actions @@ -27,30 +35,33 @@ = icon('caret-down') .dropdown-menu.dropdown-menu-align-right.hidden-lg %ul - %li - = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link' - - if can?(current_user, :update_issue, @issue) + - if can_update_issue %li - = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'issuable-edit' %li - = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), 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_by?(current_user) + = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + - if can_report_spam %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_update_issue || can_report_spam + %li.divider + %li + = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link' + - if can_update_issue + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' + = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + - if can_report_spam + = 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 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 }, 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 }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - - if @issue.submittable_as_spam_by?(current_user) - = 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 +<<<<<<< HEAD .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), "can-update-tasks-class" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', "is-edited": @issue.is_edited?, @@ -61,6 +72,17 @@ .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } .wiki.js-issue-description= markdown_field(@issue, :description) %textarea.hidden.js-task-list-field= @issue.description +======= + %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue) + #js-issuable-app + %h2.title= markdown_field(@issue, :title) + - if @issue.description.present? + .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } + .wiki= 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 js-issue-edited-ago') +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } // This element is filled in using JavaScript. diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/jobs/_header.html.haml index a0f8f105d9a..ad72ab5b199 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/jobs/_header.html.haml @@ -6,20 +6,18 @@ = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title %strong Job - = link_to namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' do - \##{@build.id} + = link_to "##{@build.id}", namespace_project_job_path(@project.namespace, @project, @build), class: 'js-build-id' in pipeline - = link_to pipeline_path(pipeline) do - %strong ##{pipeline.id} - for commit - = link_to namespace_project_commit_path(@project.namespace, @project, pipeline.sha) do - %strong= pipeline.short_sha + %strong + = link_to "##{pipeline.id}", pipeline_path(pipeline) + for + %strong + = link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: 'commit-sha' from - = link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do - %code - = @build.ref + %strong + = link_to @build.ref, project_ref_path(@project, @build.ref), class: 'ref-name' - = render "projects/builds/user" if @build.user + = render "projects/jobs/user" if @build.user = time_ago_with_tooltip(@build.created_at) @@ -28,6 +26,6 @@ - if can?(current_user, :create_issue, @project) && @build.failed? = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post + = link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } = icon('angle-double-left') diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index 26c892d0fd2..1c05129a65a 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -1,6 +1,6 @@ - builds = @build.pipeline.builds.to_a -%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "153", "spy" => "affix" } } +%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } .block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default Job %strong ##{@build.id} @@ -30,21 +30,21 @@ - if @build.artifacts? .btn-group.btn-group-justified{ role: :group } - if @build.has_expiring_artifacts? && can?(current_user, :update_build, @build) - = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do + = link_to keep_namespace_project_job_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), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do + = link_to download_namespace_project_job_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', 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 + = link_to browse_namespace_project_job_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 Job details - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post + = link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post - if @build.merge_request %p.build-detail-row %span.build-light-text Merge Request: @@ -68,15 +68,8 @@ - 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 + = link_to "Cancel", cancel_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post - if @build.trigger_request .build-widget @@ -118,7 +111,7 @@ %span.stage-selection More = icon('chevron-down') %ul.dropdown-menu - - @build.pipeline.stages.each do |stage| + - @build.pipeline.legacy_stages.each do |stage| %li %a.stage-item= stage.name @@ -126,7 +119,7 @@ - HasStatus::ORDERED_STATUSES.each do |build_status| - builds.select{|build| build.status == build_status}.each do |build| .build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } } - = link_to namespace_project_build_path(@project.namespace, @project, build) do + = link_to namespace_project_job_path(@project.namespace, @project, build) do = icon('arrow-right') %span{ class: "ci-status-icon-#{build.status}" } = ci_icon_for_status(build.status) @@ -137,6 +130,9 @@ = build.id - if build.retried? %i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } +<<<<<<< HEAD:app/views/projects/builds/_sidebar.html.haml :javascript new Sidebar(); +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259:app/views/projects/jobs/_sidebar.html.haml diff --git a/app/views/projects/builds/_table.html.haml b/app/views/projects/jobs/_table.html.haml index 82806f022ee..82806f022ee 100644 --- a/app/views/projects/builds/_table.html.haml +++ b/app/views/projects/jobs/_table.html.haml diff --git a/app/views/projects/builds/_user.html.haml b/app/views/projects/jobs/_user.html.haml index 83f299da651..83f299da651 100644 --- a/app/views/projects/builds/_user.html.haml +++ b/app/views/projects/jobs/_user.html.haml diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/jobs/index.html.haml index 65162aacda1..a33e3978ee1 100644 --- a/app/views/projects/builds/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -4,17 +4,17 @@ %div{ class: container_class } .top-area - - build_path_proc = ->(scope) { project_builds_path(@project, scope: scope) } + - build_path_proc = ->(scope) { project_jobs_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) - if @all_builds.running_or_pending.any? - = link_to 'Cancel running', cancel_all_namespace_project_builds_path(@project.namespace, @project), + = link_to 'Cancel running', cancel_all_namespace_project_jobs_path(@project.namespace, @project), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post - unless @repository.gitlab_ci_yml - = link_to 'Get started with CI/CD Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info' + = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info' = link_to ci_lint_path, class: 'btn btn-default' do %span CI lint diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/jobs/show.html.haml index 7cb2ec83cc7..0d10dfcef70 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -8,7 +8,7 @@ - if @build.stuck? - unless @build.any_runners_online? - .bs-callout.bs-callout-warning + .bs-callout.bs-callout-warning.js-build-stuck %p - if no_runners_for_project?(@build.project) This job is stuck, because the project doesn't have any runners online assigned to it. @@ -26,7 +26,7 @@ Runners page - if @build.starts_environment? - .prepend-top-default + .prepend-top-default.js-environment-container .environment-information - if @build.outdated_deployment? = ci_icon_for_status('success_with_warnings') @@ -47,39 +47,51 @@ - if environment.try(:last_deployment) and will overwrite the #{deployment_link(environment.last_deployment, text: 'latest deployment')} - .prepend-top-default + .prepend-top-default.js-build-erased - if @build.erased? .erased.alert.alert-warning - if @build.erased_by_user? Job has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)} - else Job has been erased #{time_ago_with_tooltip(@build.erased_at)} - - else - #js-build-scroll.scroll-controls - .scroll-step - %a.scroll-link.scroll-top{ href: '#up-build-trace', id: 'scroll-top', title: 'Scroll to top' } - = custom_icon('scroll_up') - = custom_icon('scroll_up_hover_active') - %a.scroll-link.scroll-bottom{ href: '#down-build-trace', id: 'scroll-bottom', title: 'Scroll to bottom' } - = custom_icon('scroll_down') - = custom_icon('scroll_down_hover_active') - - if @build.active? - .autoscroll-container - %span.status-message#autoscroll-status{ data: { state: 'disabled' } } - %span.status-text Autoscroll active - %i.status-icon - = custom_icon('scroll_down_hover_active') - #up-build-trace - %pre.build-trace#build-trace + + .prepend-top-default + .build-trace-container#build-trace + .top-bar.sticky .js-truncated-info.truncated-info.hidden< Showing last %span.js-truncated-info-size.truncated-info-size>< KiB of log - - %a.js-raw-link.raw-link{ :href => raw_namespace_project_build_path(@project.namespace, @project, @build) }>< Complete Raw - %code.bash.js-build-output - .build-loader-animation.js-build-refresh + %a.js-raw-link.raw-link{ href: raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw + .controllers + - if @build.has_trace? + = link_to raw_namespace_project_job_path(@project.namespace, @project, @build), + title: 'Open raw trace', + data: { placement: 'top', container: 'body' }, + class: 'js-raw-link-controller has-tooltip' do + = icon('download') + + - if can?(current_user, :update_build, @project) && @build.erasable? + = link_to erase_namespace_project_job_path(@project.namespace, @project, @build), + method: :post, + data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' }, + title: 'Erase Build', + class: 'has-tooltip js-erase-link' do + = icon('trash') - #down-build-trace + %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank.has-tooltip{ type: 'button', + disabled: true, + title: 'Scroll Up', + data: { placement: 'top', container: 'body'} } + = custom_icon('scroll_up') + %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank.has-tooltip{ type: 'button', + disabled: true, + title: 'Scroll Down', + data: { placement: 'top', container: 'body'} } + = custom_icon('scroll_down') + .bash.sticky.js-scroll-container + %code.js-build-output + .build-loader-animation.js-build-refresh = render "sidebar" diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index 2e6420db212..b787edb3427 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -8,4 +8,4 @@ %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 "shared/notes/notes_with_form" +#notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 11b7aaec704..c13110deb16 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -1,6 +1,6 @@ %li{ id: dom_id(merge_request), class: mr_css_classes(merge_request), data: { labels: merge_request.label_ids, id: merge_request.id } } - - if @bulk_edit - .issue-check + - if @can_bulk_update + .issue-check.hidden = check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue" .issue-info-container @@ -37,7 +37,7 @@ by #{link_to_member(@project, merge_request.author, avatar: false)} - if merge_request.target_project.default_branch != merge_request.target_branch - = link_to namespace_project_commits_path(merge_request.project.namespace, merge_request.project, merge_request.target_branch) do + = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do = icon('code-fork') = merge_request.target_branch diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml index 9cf24e10842..0f37abb579c 100644 --- a/app/views/projects/merge_requests/_new_compare.html.haml +++ b/app/views/projects/merge_requests/_new_compare.html.haml @@ -21,8 +21,8 @@ selected: f.object.source_project_id .merge-request-select.dropdown = f.hidden_field :source_branch - = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" } - .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch + = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch git-revision-dropdown-toggle" } + .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch.git-revision-dropdown = dropdown_title("Select source branch") = dropdown_filter("Search branches") = dropdown_content do @@ -51,8 +51,8 @@ selected: f.object.target_project_id .merge-request-select.dropdown = f.hidden_field :target_branch - = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch" } - .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown + = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch git-revision-dropdown-toggle" } + .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown.git-revision-dropdown = dropdown_title("Select target branch") = dropdown_filter("Search branches") = dropdown_content do diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml index da79ca2ee75..e3ecbee5490 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/_new_submit.html.haml @@ -3,9 +3,9 @@ %p.slead - source_title, target_title = format_mr_branch_names(@merge_request) From - %strong.label-branch= source_title + %strong.ref-name= source_title %span into - %strong.label-branch= target_title + %strong.ref-name= target_title %span.pull-right = link_to 'Change branches', mr_change_branches_path(@merge_request) diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 502220232a1..c082457e97b 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -1,21 +1,28 @@ - @no_container = true -- @bulk_edit = can?(current_user, :admin_merge_request, @project) +- @can_bulk_update = can?(current_user, :admin_merge_request, @project) - page_title "Merge Requests" - unless @project.default_issues_tracker? = content_for :sub_nav do = render "projects/merge_requests/head" -= render 'projects/last_push' - content_for :page_specific_javascripts do = webpack_bundle_tag 'common_vue' = webpack_bundle_tag 'filtered_search' +<<<<<<< HEAD +======= + + += render 'projects/last_push' +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - if @project.merge_requests.exists? %div{ class: container_class } .top-area = render 'shared/issuable/nav', type: :merge_requests .nav-controls + - if @can_bulk_update + = button_tag "Edit Merge Requests", class: "btn js-bulk-update-toggle" - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) - if merge_project = link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project), class: "btn btn-new", title: "New merge request" do @@ -23,6 +30,9 @@ = render 'shared/issuable/search_bar', type: :merge_requests + - if @can_bulk_update + = render 'shared/issuable/bulk_update_sidebar', type: :merge_requests + .merge-requests-holder = render 'merge_requests' - else 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 f3372c7657f..766cb272bec 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 @@ -49,7 +49,7 @@ %strong Tip: = 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', rel: 'noopener noreferrer' + = link_to 'following these guidelines', help_page_path('user/project/merge_requests/index.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer' :javascript $(function(){ 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 e7c5bca6a37..d9428b8562e 100644 --- a/app/views/projects/merge_requests/show/_mr_title.html.haml +++ b/app/views/projects/merge_requests/show/_mr_title.html.haml @@ -13,7 +13,7 @@ = icon('angle-double-left') .issuable-meta - = issuable_meta(@merge_request, @project, "Merge Request") + = issuable_meta(@merge_request, @project, "Merge request") - if can?(current_user, :update_merge_request, @merge_request) .issuable-actions diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml index 11b0c55be0b..0999b95c9c9 100644 --- a/app/views/projects/merge_requests/show/_versions.html.haml +++ b/app/views/projects/merge_requests/show/_versions.html.haml @@ -20,25 +20,27 @@ - @merge_request_diffs.each do |merge_request_diff| %li = link_to merge_request_version_path(@project, @merge_request, merge_request_diff, @start_sha), 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) + %div + %strong + - if merge_request_diff.latest? + latest version + - else + version #{version_index(merge_request_diff)} + %div + %small.commit-sha= short_sha(merge_request_diff.head_commit_sha) + %div + %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_version - version #{version_index(@start_version)} - - else - #{@merge_request.target_branch} + - if @start_version + version #{version_index(@start_version)} + - else + %span.ref-name= @merge_request.target_branch = icon('caret-down') .dropdown-menu.dropdown-select.dropdown-menu-selectable .dropdown-title @@ -50,19 +52,25 @@ - @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) + %div + %strong + - if merge_request_diff.latest? + latest version + - else + version #{version_index(merge_request_diff)} + %div + %small.commit-sha= short_sha(merge_request_diff.head_commit_sha) + %div + %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_version) do - %strong - #{@merge_request.target_branch} (base) - .monospace= short_sha(@merge_request_diff.base_commit_sha) + %div + %strong + %span.ref-name= @merge_request.target_branch + (base) + %div + %strong.commit-sha= short_sha(@merge_request_diff.base_commit_sha) - if different_base?(@start_version, @merge_request_diff) .content-block @@ -83,7 +91,7 @@ comparing two versions - else viewing an old version - of this merge request. + of the diff. .pull-right = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 9e292729425..7b8be58554a 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -30,7 +30,7 @@ #{root_url}#{current_user.username}/ = f.hidden_field :namespace_id, value: current_user.namespace_id .form-group.col-xs-12.col-sm-6.project-path - = f.label :namespace_id, class: 'label-light' do + = f.label :path, class: 'label-light' do %span Project name = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true @@ -95,7 +95,7 @@ .form-group.project-visibility-level-holder = f.label :visibility_level, class: 'label-light' do Visibility Level - = link_to icon('question-circle'), help_page_path("public_access/public_access") + = link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' } = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false = f.submit 'Create project', class: "btn btn-create project-submit", tabindex: 4 diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml index 720957e8336..1cf286ddc40 100644 --- a/app/views/projects/no_repo.html.haml +++ b/app/views/projects/no_repo.html.haml @@ -1,22 +1,22 @@ %h2 %i.fa.fa-warning - No repository + #{ _('No repository') } %p.slead - The repository for this project does not exist. + #{ _('The repository for this project does not exist.') } %br - This means you can not push code until you create an empty repository or import existing one. + #{ _('This means you can not push code until you create an empty repository or import existing one.') } %hr .no-repo-actions = link_to namespace_project_repository_path(@project.namespace, @project), method: :post, class: 'btn btn-primary' do - Create empty bare repository + #{ _('Create empty bare repository') } %strong.prepend-left-10.append-right-10 or = link_to new_namespace_project_import_path(@project.namespace, @project), class: 'btn' do - Import repository + #{ _('Import repository') } - if can? current_user, :remove_project, @project .prepend-top-20 - = link_to 'Remove project', project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right" + = link_to _('Remove project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right" diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index d70ec8a6062..9c42be4e0ff 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -31,14 +31,10 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do + = link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' 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 js-note-delete danger has-tooltip' do - = icon('trash-o', class: 'danger-highlight') + = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml new file mode 100644 index 00000000000..e0d45054854 --- /dev/null +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -0,0 +1,14 @@ +.dropdown.more-actions + = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn btn-transparent', data: { toggle: 'dropdown', container: 'body' } do + = icon('ellipsis-v', class: 'icon') + %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left + %li + = button_tag 'Edit comment', class: 'js-note-edit btn btn-transparent' + %li.divider + %li + = link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do + Report as abuse + - if note_editable + %li + = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?' }, remote: true, class: 'js-note-delete' do + %span.text-danger Delete comment diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 075ecee4343..7bde839e26f 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -4,7 +4,8 @@ = pipeline_schedule.description %td.branch-name-cell = icon('code-fork') - = link_to pipeline_schedule.ref, namespace_project_commits_path(@project.namespace, @project, pipeline_schedule.ref), class: "branch-name" + - if pipeline_schedule.ref + = link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name" %td - if pipeline_schedule.last_pipeline .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" } @@ -15,7 +16,7 @@ None %td.next-run-cell - if pipeline_schedule.active? - = time_ago_with_tooltip(pipeline_schedule.next_run_at) + = time_ago_with_tooltip(pipeline_schedule.real_next_run) - else Inactive %td diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index db9d77dba16..a33da149c62 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -11,7 +11,7 @@ - if project_nav_tab? :builds = nav_link(controller: [:builds, :artifacts]) do - = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do + = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do %span Jobs diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index ab6baaf35b6..673c3370b62 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -1,18 +1,4 @@ -.page-content-header - .header-main-content - = render 'ci/status/badge', status: @pipeline.detailed_status(current_user), title: @pipeline.status_title - %strong Pipeline ##{@pipeline.id} - triggered #{time_ago_with_tooltip(@pipeline.created_at)} - - if @pipeline.user - by - = user_avatar(user: @pipeline.user, size: 24) - = user_link(@pipeline.user) - .header-action-buttons - - if can?(current_user, :update_pipeline, @pipeline.project) - - if @pipeline.retryable? - = link_to "Retry", retry_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'js-retry-button btn btn-inverted-secondary', method: :post - - if @pipeline.cancelable? - = link_to "Cancel running", cancel_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post +#js-pipeline-header-vue.pipeline-header-container - if @commit .commit-box @@ -30,7 +16,7 @@ = pluralize @pipeline.statuses.count(:id), "job" - if @pipeline.ref from - = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace" + = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" - if @pipeline.duration in = time_interval_in_words(@pipeline.duration) @@ -40,10 +26,10 @@ .well-segment.branch-info .icon-container.commit-icon = custom_icon("icon_commit") - = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace js-details-short" + = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "commit-sha js-details-short" = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do %span.text-expander \... %span.js-details-content.hide - = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full" + = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "commit-sha commit-hash-full" = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard") diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 075ddc0025c..85550e8fd32 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,9 +1,5 @@ - failed_builds = @pipeline.statuses.latest.failed -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('pipelines_graph') - .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom %li.js-pipeline-tab-link @@ -21,7 +17,7 @@ .tab-content #js-tab-pipeline.tab-pane - #js-pipeline-graph-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } } + #js-pipeline-graph-vue #js-tab-builds.tab-pane - if pipeline.yaml_errors.present? @@ -46,7 +42,7 @@ %th %th Coverage %th - = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage + = render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage - if failed_builds.present? #js-tab-failures.build-failures.tab-pane - failed_builds.each_with_index do |build, index| @@ -55,5 +51,5 @@ %span.stage = build.stage.titleize %span.build-name - = link_to build.name, pipeline_build_url(pipeline, build) + = link_to build.name, pipeline_job_url(pipeline, build) %pre.build-log= build_summary(build, skip: index >= 10) diff --git a/app/views/projects/pipelines/charts/_overall.haml b/app/views/projects/pipelines/charts/_overall.haml index edc4f7b079f..0b7e3d22dd7 100644 --- a/app/views/projects/pipelines/charts/_overall.haml +++ b/app/views/projects/pipelines/charts/_overall.haml @@ -2,13 +2,13 @@ %ul %li Total: - %strong= pluralize @project.builds.count(:all), 'build' + %strong= pluralize @project.builds.count(:all), 'job' %li Successful: - %strong= pluralize @project.builds.success.count(:all), 'build' + %strong= pluralize @project.builds.success.count(:all), 'job' %li Failed: - %strong= pluralize @project.builds.failed.count(:all), 'build' + %strong= pluralize @project.builds.failed.count(:all), 'job' %li Success ratio: %strong diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 14a270a3039..71a8e490c3e 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -11,8 +11,8 @@ .col-sm-10 = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch = dropdown_tag(params[:ref] || @project.default_branch, - options: { toggle_class: 'js-branch-select wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches", + options: { toggle_class: 'js-branch-select wide git-revision-dropdown-toggle', + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search branches", data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) .help-block Existing branch name, tag .form-actions diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 49c1d886423..b39453a50fb 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -7,3 +7,9 @@ = render "projects/pipelines/info" = render "projects/pipelines/with_tabs", pipeline: @pipeline + +.js-pipeline-details-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } } + +- content_for :page_specific_javascripts do + = webpack_bundle_tag('common_vue') + = webpack_bundle_tag('pipelines_details') diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml index a3f84476dea..3b17daeb6da 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/pipelines_settings/_show.html.haml @@ -1,14 +1,14 @@ .row.prepend-top-default .col-lg-3.profile-settings-sidebar %h4.prepend-top-0 - CI/CD Pipelines + Pipelines .col-lg-9 = form_for @project, url: namespace_project_pipelines_settings_path(@project.namespace.becomes(Namespace), @project) do |f| %fieldset.builds-feature - unless @repository.gitlab_ci_yml .form-group %p Pipelines need to be configured before you can begin using Continuous Integration. - = link_to 'Get started with CI/CD Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info' + = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info' %hr .form-group.append-bottom-default = f.label :runners_token, "Runner token", class: 'label-light' @@ -42,7 +42,7 @@ = f.label :build_timeout_in_minutes, 'Timeout', class: 'label-light' = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0' %p.help-block - Per job in minutes. If a job passes this threshold, it will be marked as failed. + Per job in minutes. If a job passes this threshold, it will be marked as failed = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' %hr diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml index 5af0cc7a2f3..6e9c473494e 100644 --- a/app/views/projects/protected_branches/_dropdown.html.haml +++ b/app/views/projects/protected_branches/_dropdown.html.haml @@ -1,8 +1,8 @@ = f.hidden_field(:name) = 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", + options: { toggle_class: 'js-protected-branch-select js-filter-submit wide git-revision-dropdown-toggle', + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search protected branches", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_branch_name], diff --git a/app/views/projects/protected_branches/_index.html.haml b/app/views/projects/protected_branches/_index.html.haml index 2d8c519c025..9af67649741 100644 --- a/app/views/projects/protected_branches/_index.html.haml +++ b/app/views/projects/protected_branches/_index.html.haml @@ -1,20 +1,25 @@ +- expanded = Rails.env.test? - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('protected_branches') -.row.prepend-top-default.append-bottom-default - .col-lg-3 - %h4.prepend-top-0 +%section.settings + .settings-header + %h4 Protected Branches - %p Keep stable branches secure and force developers to use merge requests. - %p.prepend-top-20 + %button.btn.js-settings-toggle + = expanded ? 'Close' : 'Expand' + %p + Keep stable branches secure and force developers to use merge requests. + .settings-content.no-animate{ class: ('expanded' if expanded) } + %p By default, protected branches are designed to: %ul %li prevent their creation, if not already created, from everybody except Masters %li prevent pushes from everybody except Masters %li prevent <strong>anyone</strong> from force pushing to the branch %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 + %p 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"}. + - if can? current_user, :admin_project, @project = render 'projects/protected_branches/create_protected_branch' diff --git a/app/views/projects/protected_branches/_matching_branch.html.haml b/app/views/projects/protected_branches/_matching_branch.html.haml index 8a5332ca5bb..27896272733 100644 --- a/app/views/projects/protected_branches/_matching_branch.html.haml +++ b/app/views/projects/protected_branches/_matching_branch.html.haml @@ -1,9 +1,10 @@ %tr %td - = link_to matching_branch.name, namespace_project_tree_path(@project.namespace, @project, matching_branch.name) + = link_to matching_branch.name, project_ref_path(@project, matching_branch.name), class: 'ref-name' + - if @project.root_ref?(matching_branch.name) %span.label.label-info.prepend-left-5 default %td - commit = @project.commit(matching_branch.name) - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index b2a6b8469a3..0f80de94392 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -1,6 +1,7 @@ %tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } } %td - = protected_branch.name + %span.ref-name= protected_branch.name + - if @project.root_ref?(protected_branch.name) %span.label.label-info.prepend-left-5 default %td @@ -9,7 +10,7 @@ = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) - else - if commit = protected_branch.commit - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) - else (branch was removed from repository) diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml index f8cfe5e4b11..a806a0756ec 100644 --- a/app/views/projects/protected_branches/show.html.haml +++ b/app/views/projects/protected_branches/show.html.haml @@ -2,7 +2,7 @@ .row.prepend-top-default.append-bottom-default .col-lg-3 - %h4.prepend-top-0 + %h4.prepend-top-0.ref-name = @protected_ref.name .col-lg-9 diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml index af9a080f0a2..dd5b346d922 100644 --- a/app/views/projects/protected_tags/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'js-new-protected-tag' } do |f| += form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f| .panel.panel-default .panel-heading %h3.panel-title diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml index c50515cfe06..9b6923210f7 100644 --- a/app/views/projects/protected_tags/_dropdown.html.haml +++ b/app/views/projects/protected_tags/_dropdown.html.haml @@ -1,8 +1,8 @@ = f.hidden_field(:name) = dropdown_tag('Select tag or create wildcard', - options: { toggle_class: 'js-protected-tag-select js-filter-submit wide', - filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header", placeholder: "Search protected tag", + options: { toggle_class: 'js-protected-tag-select js-filter-submit wide git-revision-dropdown-toggle', + filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tags", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_tag_name], @@ -10,6 +10,6 @@ %ul.dropdown-footer-list %li - = link_to '#', title: "New Protected Tag", class: "create-new-protected-tag" do + %button{ class: "create-new-protected-tag-button js-create-new-protected-tag", title: "New Protected Tag" } Create wildcard %code diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml index 0bfb1ad191d..976e1d7e93f 100644 --- a/app/views/projects/protected_tags/_index.html.haml +++ b/app/views/projects/protected_tags/_index.html.haml @@ -1,17 +1,25 @@ +- expanded = Rails.env.test? - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('protected_tags') -.row.prepend-top-default.append-bottom-default - .col-lg-3 - %h4.prepend-top-0 - Protected tags - %p.prepend-top-20 - By default, Protected tags are designed to: +%section.settings + .settings-header + %h4 + Protected Tags + %button.btn.js-settings-toggle + = expanded ? 'Close' : 'Expand' + %p + Limit access to creating and updating tags. + .settings-content.no-animate{ class: ('expanded' if expanded) } + %p + By default, protected tags are designed to: %ul %li Prevent tag creation by everybody except Masters %li Prevent <strong>anyone</strong> from updating the tag %li Prevent <strong>anyone</strong> from deleting the tag - .col-lg-9 + + %p Read more about #{link_to "protected tags", help_page_path("user/project/protected_tags"), class: "underlined-link"}. + - if can? current_user, :admin_project, @project = render 'projects/protected_tags/create_protected_tag' diff --git a/app/views/projects/protected_tags/_matching_tag.html.haml b/app/views/projects/protected_tags/_matching_tag.html.haml index 97e5cd6f9d2..f17353df122 100644 --- a/app/views/projects/protected_tags/_matching_tag.html.haml +++ b/app/views/projects/protected_tags/_matching_tag.html.haml @@ -1,9 +1,10 @@ %tr %td - = link_to matching_tag.name, namespace_project_tree_path(@project.namespace, @project, matching_tag.name) + = link_to matching_tag.name, project_ref_path(@project, matching_tag.name), class: 'ref-name' + - if @project.root_ref?(matching_tag.name) %span.label.label-info.prepend-left-5 default %td - commit = @project.commit(matching_tag.name) - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml index 26bd3a1f5ed..f11ce0483a9 100644 --- a/app/views/projects/protected_tags/_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_protected_tag.html.haml @@ -1,6 +1,7 @@ %tr.js-protected-tag-edit-form{ data: { url: namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) } } %td - = protected_tag.name + %span.ref-name= protected_tag.name + - if @project.root_ref?(protected_tag.name) %span.label.label-info.prepend-left-5 default %td @@ -9,7 +10,7 @@ = link_to pluralize(matching_tags.count, "matching tag"), namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) - else - if commit = protected_tag.commit - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) - else (tag was removed from repository) @@ -18,4 +19,4 @@ - if can_admin_project %td - = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' + = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/protected_tags/_tags_list.html.haml b/app/views/projects/protected_tags/_tags_list.html.haml index 728afd75b50..d432a5c9113 100644 --- a/app/views/projects/protected_tags/_tags_list.html.haml +++ b/app/views/projects/protected_tags/_tags_list.html.haml @@ -1,4 +1,4 @@ -.panel.panel-default.protected-tags-list.js-protected-tags-list +.panel.panel-default.protected-tags-list - if @protected_tags.empty? .panel-heading %h3.panel-title @@ -13,6 +13,8 @@ %col{ width: "25%" } %col{ width: "25%" } %col{ width: "50%" } + - if can_admin_project + %col %thead %tr %th Protected tag (#{@protected_tags.size}) diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml index 63743f28b3c..16fc02fe9f4 100644 --- a/app/views/projects/protected_tags/show.html.haml +++ b/app/views/projects/protected_tags/show.html.haml @@ -2,10 +2,10 @@ .row.prepend-top-default.append-bottom-default .col-lg-3 - %h4.prepend-top-0 + %h4.prepend-top-0.ref-name = @protected_ref.name - .col-lg-9 + .col-lg-9.edit_protected_tag %h5 Matching Tags - if @matching_refs.present? .table-responsive diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index be128e92fa7..5661af01302 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -1,26 +1,60 @@ - page_title "Container Registry" -%hr - -%ul.content-list - %li.light.prepend-top-default +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + = page_title %p - A 'container image' is a snapshot of a container. - You can host your container images with GitLab. - %br - To start using container images hosted on GitLab you first need to login: - %pre - %code + With the Docker Container Registry integrated into GitLab, every project + can have its own space to store its Docker images. + %p.append-bottom-0 + = succeed '.' do + Learn more about + = link_to 'Container Registry', help_page_path('user/project/container_registry'), target: '_blank' + + .col-lg-9 + .panel.panel-default + .panel-heading + %h4.panel-title + How to use the Container Registry + .panel-body + %p + First log in to GitLab’s Container Registry using your GitLab username + and password. If you have + = link_to '2FA enabled', help_page_path('user/profile/account/two_factor_authentication'), target: '_blank' + you need to use a + = succeed ':' do + = link_to 'personal access token', help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank' + %pre docker login #{Gitlab.config.registry.host_port} - %br - Then you are free to create and upload a container image with build and push commands: - %pre - docker build -t #{escape_once(@project.container_registry_url)}/image . %br - docker push #{escape_once(@project.container_registry_url)}/image + %p + Once you log in, you’re free to create and upload a container image + using the common + %code build + and + %code push + commands: + %pre + :plain + docker build -t #{escape_once(@project.container_registry_url)} . + docker push #{escape_once(@project.container_registry_url)} - - if @images.blank? - .nothing-here-block No container image repositories in Container Registry for this project. + %hr + %h5.prepend-top-default + Use different image names + %p.light + GitLab supports up to 3 levels of image names. The following + examples of images are valid for your project: + %pre + :plain + #{escape_once(@project.container_registry_url)}:tag + #{escape_once(@project.container_registry_url)}/optional-image-name:tag + #{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag - - else - = render partial: 'image', collection: @images + - if @images.blank? + %p.settings-message.text-center.append-bottom-default + No container images stored for this project. Add one by following the + instructions above. + - else + = render partial: 'image', collection: @images diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index deeadb609f6..674f87e8220 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -1,15 +1,18 @@ %li.runner{ id: dom_id(runner) } %h4 = runner_status_icon(runner) - %span.monospace - - if @project_runners.include?(runner) - = link_to runner.short_sha, runner_path(runner) - - if runner.locked? - = icon('lock', class: 'has-tooltip', title: 'Locked to current projects') - %small - = link_to edit_namespace_project_runner_path(@project.namespace, @project, runner) do - %i.fa.fa-edit.btn - - else + + - if @project_runners.include?(runner) + = link_to runner.short_sha, runner_path(runner), class: 'commit-sha' + + - if runner.locked? + = icon('lock', class: 'has-tooltip', title: 'Locked to current projects') + + %small + = link_to edit_namespace_project_runner_path(@project.namespace, @project, runner) do + %i.fa.fa-edit.btn + - else + %span.commit-sha = runner.short_sha .pull-right diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml index 6b8e6bd4fee..f8835454140 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_specific_runners.html.haml @@ -9,7 +9,7 @@ (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 the Runner setup: - %code= ci_root_url(only_path: false) + %code= root_url(only_path: false) %li Use the following registration token during setup: %code= @project.runners_token diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index f1a80f1d5e1..9167789a69d 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -1,3 +1,6 @@ +- content_for :page_specific_javascripts do + = webpack_bundle_tag('integrations') + .row.prepend-top-default.append-bottom-default .col-lg-3 %h4.prepend-top-0 @@ -6,15 +9,17 @@ %p= @service.description .col-lg-9 - = form_for(@service, as: :service, url: namespace_project_service_path(@project.namespace, @project, @service.to_param), method: :put, html: { class: 'form-horizontal' }) do |form| + = form_for(@service, as: :service, url: namespace_project_service_path(@project.namespace, @project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_namespace_project_service_path } }) do |form| = render 'shared/service_settings', form: form, subject: @service .footer-block.row-content-block - = form.submit 'Save changes', class: 'btn btn-save' + %button.btn.btn-save{ type: 'submit' } + = icon('spinner spin', class: 'hidden js-btn-spinner') + %span.js-btn-label + Save changes - if @service.valid? && @service.activated? - unless @service.can_test? - disabled_class = 'disabled' - disabled_title = @service.disabled_title - = link_to 'Test settings', test_namespace_project_service_path(@project.namespace, @project, @service), class: "btn #{disabled_class}", title: disabled_title - = link_to "Cancel", namespace_project_settings_integrations_path(@project.namespace, @project), class: "btn btn-cancel" + = link_to 'Cancel', namespace_project_settings_integrations_path(@project.namespace, @project), class: 'btn btn-cancel' diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml index 8c7f9e0191e..00bd563999f 100644 --- a/app/views/projects/settings/_head.html.haml +++ b/app/views/projects/settings/_head.html.haml @@ -14,7 +14,7 @@ %span Members - if can_edit - = nav_link(controller: [:integrations, :services, :hooks]) do + = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do = link_to project_settings_integrations_path(@project), title: 'Integrations' do %span Integrations @@ -24,9 +24,9 @@ Repository - if @project.feature_available?(:builds, current_user) = nav_link(controller: :ci_cd) do - = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'CI/CD Pipelines' do + = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'Pipelines' do %span - CI/CD Pipelines + Pipelines - if Gitlab.config.pages.enabled = nav_link(controller: :pages) do = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index e2603096014..e8d2e91bd76 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -1,4 +1,4 @@ -- page_title "CI/CD Pipelines" +- page_title "Pipelines" = render "projects/settings/head" = render 'projects/runners/index' diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml index 8dc276a3bec..a6640592dba 100644 --- a/app/views/projects/settings/integrations/_project_hook.html.haml +++ b/app/views/projects/settings/integrations/_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 confidential_issues_events note_events merge_requests_events build_events pipeline_events wiki_page_events).each do |trigger| + - %w(push_events tag_push_events issues_events confidential_issues_events note_events merge_requests_events job_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/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 4e59033c4a3..40ea02abce9 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,10 +1,11 @@ - page_title "Repository" +- @content_class = "limit-container-width" unless fluid_layout = render "projects/settings/head" - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('deploy_keys') -= render @deploy_keys = render "projects/protected_branches/index" = render "projects/protected_tags/index" += render @deploy_keys diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index d6c4195e2d0..7447197ed89 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -17,24 +17,24 @@ %ul.nav %li = link_to project_files_path(@project) do - Files (#{storage_counter(@project.statistics.total_repository_size)}) + #{_('Files')} (#{storage_counter(@project.statistics.total_repository_size)}) %li = link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do - #{'Commit'.pluralize(@project.statistics.commit_count)} (#{number_with_delimiter(@project.statistics.commit_count)}) - %li + #{n_('Commit', 'Commits', @project.statistics.commit_count)} (#{number_with_delimiter(@project.statistics.commit_count)}) + %l = link_to namespace_project_branches_path(@project.namespace, @project) do - #{'Branch'.pluralize(@repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)}) + #{n_('Branch', 'Branches', @repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)}) %li = link_to namespace_project_tags_path(@project.namespace, @project) do - #{'Tag'.pluralize(@repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)}) + #{n_('Tag', 'Tags', @repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)}) - if default_project_view != 'readme' && @repository.readme %li - = link_to 'Readme', readme_path(@project) + = link_to _('Readme'), readme_path(@project) - if @repository.changelog %li - = link_to 'Changelog', changelog_path(@project) + = link_to _('Changelog'), changelog_path(@project) - if @repository.license_blob %li @@ -42,48 +42,43 @@ - if @repository.contribution_guide %li - = link_to 'Contribution guide', contribution_guide_path(@project) + = link_to _('Contribution guide'), contribution_guide_path(@project) - if @repository.gitlab_ci_yml %li - = link_to 'CI configuration', ci_configuration_path(@project) + = link_to _('CI configuration'), ci_configuration_path(@project) - if current_user && can_push_branch?(@project, @project.default_branch) - unless @repository.changelog %li.missing = link_to add_special_file_path(@project, file_name: 'CHANGELOG') do - Add Changelog + #{ _('Add Changelog') } - unless @repository.license_blob %li.missing = link_to add_special_file_path(@project, file_name: 'LICENSE') do - Add License + #{ _('Add License') } - unless @repository.contribution_guide %li.missing = link_to add_special_file_path(@project, file_name: 'CONTRIBUTING.md', commit_message: 'Add contribution guide') do - Add Contribution guide + #{ _('Add Contribution guide') } - unless @repository.gitlab_ci_yml %li.missing = link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml') do - Set up CI + #{ _('Set up CI') } - if koding_enabled? && @repository.koding_yml.blank? %li.missing - = link_to 'Set up Koding', add_koding_stack_path(@project) + = link_to _('Set up Koding'), add_koding_stack_path(@project) - if @repository.gitlab_ci_yml.blank? && @project.deployment_service.present? %li.missing = link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml', commit_message: 'Set up auto deploy', branch_name: 'auto-deploy', context: 'autodeploy') do - Set up auto deploy - - - if @repository.commit - %div{ class: container_class } - .project-last-commit - = render 'projects/last_commit', commit: @repository.commit, ref: current_ref, project: @project + #{ _('Set up auto deploy') } %div{ class: container_class } - if @project.archived? .text-warning.center.prepend-top-20 %p = icon("exclamation-triangle fw") - Archived project! Repository is read-only + #{ _('Archived project! Repository is read-only') } - view_path = default_project_view diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index aab1c043e66..847f3c2f348 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -9,4 +9,4 @@ .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes= render "shared/notes/notes_with_form" + #notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 4c4f3655b97..44cb734d7b9 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -2,10 +2,9 @@ - release = @releases.find { |release| release.tag == tag.name } %li.flex-row .row-main-content.str-truncated - = link_to namespace_project_tag_path(@project.namespace, @project, tag.name) do - %span.item-title - = icon('tag') - = tag.name + = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: 'item-title ref-name' do + = icon('tag') + = tag.name - if protected_tag?(@project, tag) %span.label.label-success diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index ce0eaff3060..52af295bddd 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -27,14 +27,14 @@ .form-group = label_tag :message, nil, class: 'control-label' .col-sm-10 - = text_area_tag :message, nil, required: false, tabindex: 3, class: 'form-control', rows: 5 + = text_area_tag :message, @message, required: false, tabindex: 3, class: 'form-control', rows: 5 .help-block Optionally, add a message to the tag. %hr .form-group = label_tag :release_description, 'Release notes', class: 'control-label' .col-sm-10 = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." + = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here...", current_text: @release_description = render 'shared/notes/hints' .help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page. .form-actions diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index e996ae3e4fc..2b81ce4b9fa 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -6,7 +6,9 @@ .top-area.multi-line .nav-text .title - %span.item-title= @tag.name + %span.item-title.ref-name + = icon('tag') + = @tag.name - if protected_tag?(@project, @tag) %span.label.label-success protected diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index 01599060844..de57cd4ba00 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,8 +1,9 @@ -%article.file-holder.readme-holder - .js-file-title.file-title - = blob_icon readme.mode, readme.name - = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, @path, readme.name)) do - %strong - = readme.name - .file-content.wiki - = markup(readme.name, readme.data) +- if readme.rich_viewer + %article.file-holder.readme-holder + .js-file-title.file-title + = blob_icon readme.mode, readme.name + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path)) do + %strong + = readme.name + + = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path), viewer: :rich, format: :json) diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index 2497a2d91b1..7854e1305db 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -3,20 +3,10 @@ %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } %thead %tr - %th Name + %th= s_('ProjectFileTree|Name') %th.hidden-xs - .pull-left Last commit - .last-commit.hidden-sm.pull-left - %i.fa.fa-angle-right - %small.light - = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace" - = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard") - = 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 + .pull-left= _('Last commit') + %th.text-right= _('Last Update') - if @path.present? %tr.tree-item %td.tree-item-file-name @@ -30,7 +20,7 @@ = render "projects/tree/readme", readme: tree.readme - if can_edit_tree? - = render 'projects/blob/upload', title: 'Upload New File', placeholder: 'Upload new file', button_title: 'Upload file', form_path: namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post + = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post = render 'projects/blob/new_dir' :javascript diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 396d1ecd77b..abde2a48587 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,5 +1,8 @@ .tree-controls = render 'projects/find_file_link' + + = link_to s_('Commits|History'), namespace_project_commits_path(@project.namespace, @project, @id), class: 'btn btn-grouped' + = render 'projects/buttons/download', project: @project, ref: @ref .tree-ref-holder @@ -16,7 +19,7 @@ - if current_user %li - if !on_top_of_branch? - %span.btn.add-to-tree.disabled.has-tooltip{ title: "You can only add files when you are on a branch", data: { container: 'body' } } + %span.btn.add-to-tree.disabled.has-tooltip{ title: _("You can only add files when you are on a branch"), data: { container: 'body' } } = icon('plus') - else %span.dropdown @@ -27,15 +30,15 @@ %li = link_to namespace_project_new_blob_path(@project.namespace, @project, @id) do = icon('pencil fw') - New file + #{ _('New file') } %li = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do = icon('file fw') - Upload file + #{ _('Upload file') } %li = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do = icon('folder fw') - New directory + #{ _('New directory') } - elsif can?(current_user, :fork_project, @project) %li - continue_params = { to: namespace_project_new_blob_path(@project.namespace, @project, @id), @@ -45,7 +48,7 @@ continue: continue_params) = link_to fork_path, method: :post do = icon('pencil fw') - New file + #{ _('New file') } %li - continue_params = { to: request.fullpath, notice: edit_in_new_fork_notice + " Try to upload a file again.", @@ -54,7 +57,7 @@ continue: continue_params) = link_to fork_path, method: :post do = icon('file fw') - Upload file + #{ _('Upload file') } %li - continue_params = { to: request.fullpath, notice: edit_in_new_fork_notice + " Try to create a new directory again.", @@ -63,14 +66,14 @@ continue: continue_params) = link_to fork_path, method: :post do = icon('folder fw') - New directory + #{ _('New directory') } %li.divider %li = link_to new_namespace_project_branch_path(@project.namespace, @project) do = icon('code-fork fw') - New branch + #{ _('New branch') } %li = link_to new_namespace_project_tag_path(@project.namespace, @project) do = icon('tags fw') - New tag + #{ _('New tag') } diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 910d765aed0..96a08f9f8be 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -1,10 +1,11 @@ - @no_container = true -- page_title @path.presence || "Files", @ref +- page_title @path.presence || _("Files"), @ref = content_for :meta_tags do = auto_discovery_link_tag(:atom, namespace_project_commits_url(@project.namespace, @project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") = render "projects/commits/head" + = render 'projects/last_push' %div{ class: container_class } - = render 'projects/files' + = render 'projects/files', commit: @last_commit, project: @project, ref: @ref diff --git a/app/views/projects/variables/_content.html.haml b/app/views/projects/variables/_content.html.haml index 06477aba103..98f618ca3b8 100644 --- a/app/views/projects/variables/_content.html.haml +++ b/app/views/projects/variables/_content.html.haml @@ -1,7 +1,8 @@ %h4.prepend-top-0 - Secret Variables + Secret variables + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank' %p - These variables will be set to environment by the runner. + These variables will be set to environment by the runner, and could be protected by exposing only to protected branches or tags. %p So you can use them for passwords, secret keys or whatever you want. %p diff --git a/app/views/projects/variables/_form.html.haml b/app/views/projects/variables/_form.html.haml index 1ae86d258af..0a70a301cb4 100644 --- a/app/views/projects/variables/_form.html.haml +++ b/app/views/projects/variables/_form.html.haml @@ -7,4 +7,13 @@ .form-group = f.label :value, "Value", class: "label-light" = f.text_area :value, class: "form-control", placeholder: "PROJECT_VARIABLE" + .form-group + .checkbox + = f.label :protected do + = f.check_box :protected + %strong Protected + .help-block + This variable will be passed only to pipelines running on protected branches and tags + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'protected-secret-variables'), target: '_blank' + = f.submit btn_text, class: "btn btn-save" diff --git a/app/views/projects/variables/_table.html.haml b/app/views/projects/variables/_table.html.haml index 0ce597dcf21..59cd3c4b592 100644 --- a/app/views/projects/variables/_table.html.haml +++ b/app/views/projects/variables/_table.html.haml @@ -3,10 +3,12 @@ %colgroup %col %col + %col %col{ width: 100 } %thead %th Key %th Value + %th Protected %th %tbody - @project.variables.order_key_asc.each do |variable| @@ -14,6 +16,7 @@ %tr %td.variable-key= variable.key %td.variable-value{ "data-value" => variable.value }****** + %td.variable-protected= Gitlab::Utils.boolean_to_yes_no(variable.protected) %td.variable-menu = link_to namespace_project_variable_path(@project.namespace, @project, variable), class: "btn btn-transparent btn-variable-edit" do %span.sr-only diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml index 713b758727e..c2f9e65015d 100644 --- a/app/views/projects/wikis/_sidebar.html.haml +++ b/app/views/projects/wikis/_sidebar.html.haml @@ -1,4 +1,4 @@ -%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" } } +%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" } } .block.wiki-sidebar-header.append-bottom-default %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-wiki-toggle{ href: "#" } = icon('angle-double-right') diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index 059a0d1ac78..314d8e9cb25 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -3,41 +3,48 @@ .fade-right= icon('angle-right') %ul.nav-links.search-filter.scrolling-tabs - if @project - %li{ class: active_when(@scope == 'blobs') } - = link_to search_filter_path(scope: 'blobs') do - Code - %span.badge - = @search_results.blobs_count - %li{ class: active_when(@scope == 'issues') } - = link_to search_filter_path(scope: 'issues') do - Issues - %span.badge - = @search_results.issues_count - %li{ class: active_when(@scope == 'merge_requests') } - = link_to search_filter_path(scope: 'merge_requests') do - Merge requests - %span.badge - = @search_results.merge_requests_count - %li{ class: active_when(@scope == 'milestones') } - = link_to search_filter_path(scope: 'milestones') do - Milestones - %span.badge - = @search_results.milestones_count - %li{ class: active_when(@scope == 'notes') } - = link_to search_filter_path(scope: 'notes') do - Comments - %span.badge - = @search_results.notes_count - %li{ class: active_when(@scope == 'wiki_blobs') } - = link_to search_filter_path(scope: 'wiki_blobs') do - Wiki - %span.badge - = @search_results.wiki_blobs_count - %li{ class: active_when(@scope == 'commits') } - = link_to search_filter_path(scope: 'commits') do - Commits - %span.badge - = @search_results.commits_count + - if project_search_tabs?(:blobs) + %li{ class: active_when(@scope == 'blobs') } + = link_to search_filter_path(scope: 'blobs') do + Code + %span.badge + = @search_results.blobs_count + - if project_search_tabs?(:issues) + %li{ class: active_when(@scope == 'issues') } + = link_to search_filter_path(scope: 'issues') do + Issues + %span.badge + = @search_results.issues_count + - if project_search_tabs?(:merge_requests) + %li{ class: active_when(@scope == 'merge_requests') } + = link_to search_filter_path(scope: 'merge_requests') do + Merge requests + %span.badge + = @search_results.merge_requests_count + - if project_search_tabs?(:milestones) + %li{ class: active_when(@scope == 'milestones') } + = link_to search_filter_path(scope: 'milestones') do + Milestones + %span.badge + = @search_results.milestones_count + - if project_search_tabs?(:notes) + %li{ class: active_when(@scope == 'notes') } + = link_to search_filter_path(scope: 'notes') do + Comments + %span.badge + = @search_results.notes_count + - if project_search_tabs?(:wiki) + %li{ class: active_when(@scope == 'wiki_blobs') } + = link_to search_filter_path(scope: 'wiki_blobs') do + Wiki + %span.badge + = @search_results.wiki_blobs_count + - if project_search_tabs?(:commits) + %li{ class: active_when(@scope == 'commits') } + = link_to search_filter_path(scope: 'commits') do + Commits + %span.badge + = @search_results.commits_count - elsif @show_snippets %li{ class: active_when(@scope == 'snippet_blobs') } diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml index 938be20c7cf..e43796e9654 100644 --- a/app/views/search/_filter.html.haml +++ b/app/views/search/_filter.html.haml @@ -3,7 +3,7 @@ - if params[:project_id].present? = hidden_field_tag :project_id, params[:project_id] .dropdown - %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:" } } + %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:", group_id: params[:group_id] } } %span.dropdown-toggle-text Group: - if @group.present? diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index 9ce6a1aeef5..de52fd00157 100644 --- a/app/views/sent_notifications/unsubscribe.html.haml +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -1,16 +1,14 @@ - noteable = @sent_notification.noteable -- noteable_type = @sent_notification.noteable_type.humanize(capitalize: false) +- noteable_type = @sent_notification.noteable_type.titleize.downcase - noteable_text = %(#{noteable.title} (#{noteable.to_reference})) - -- page_title "Unsubscribe", noteable_text, @sent_notification.noteable_type.humanize.pluralize, @sent_notification.project.name_with_namespace - +- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.name_with_namespace %h3.page-title - Unsubscribe from #{noteable_type} #{noteable_text} + Unsubscribe from #{noteable_type} %p = succeed '?' do - Are you sure you want to unsubscribe from #{noteable_type} + Are you sure you want to unsubscribe from the #{noteable_type}: = link_to noteable_text, url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable]) %p diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 34a4d7398bc..0aad4d0714f 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -17,9 +17,9 @@ %li = http_clone_button(project) - = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true + = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } .input-group-btn - = clipboard_button(target: '#project_clone', title: "Copy URL to clipboard") + = clipboard_button(target: '#project_clone', title: _("Copy URL to clipboard")) :javascript $('ul.clone-options-dropdown a').on('click',function(e){ diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml index 8d6e16f74c3..795447a9ca6 100644 --- a/app/views/shared/_field.html.haml +++ b/app/views/shared/_field.html.haml @@ -3,25 +3,26 @@ - value = @service.send(name) - type = field[:type] - placeholder = field[:placeholder] +- required = field[:required] - choices = field[:choices] - default_choice = field[:default_choice] - help = field[:help] .form-group - if type == "password" && value.present? - = form.label name, "Change #{title}", class: "control-label" + = form.label name, "Enter new #{title.downcase}", class: "control-label" - else = form.label name, title, class: "control-label" .col-sm-10 - if type == 'text' - = form.text_field name, class: "form-control", placeholder: placeholder + = form.text_field name, class: "form-control", placeholder: placeholder, required: required - elsif type == 'textarea' - = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder + = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required - elsif type == 'checkbox' = form.check_box name - elsif type == 'select' = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control" } - elsif type == 'password' - = form.password_field name, autocomplete: "new-password", class: 'form-control' + = form.password_field name, autocomplete: "new-password", class: "form-control", required: value.blank? && :required - if help %span.help-block= help diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 90ae3f06a98..8d5b5129454 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -15,7 +15,7 @@ %strong= parent.full_path + '/' = f.text_field :path, placeholder: 'open-source', class: 'form-control', autofocus: local_assigns[:autofocus] || false, required: true, - pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS, + pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, title: 'Please choose a group path with no special characters.', "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - if parent diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml index 07970ad9cba..aa93572bf94 100644 --- a/app/views/shared/_mini_pipeline_graph.html.haml +++ b/app/views/shared/_mini_pipeline_graph.html.haml @@ -1,5 +1,5 @@ .stage-cell - - pipeline.stages.each do |stage| + - pipeline.legacy_stages.each do |stage| - if stage.status - detailed_status = stage.detailed_status(current_user) - icon_status = "#{detailed_status.icon}_borderless" diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml index fbbf6f358c5..9ed844cf5e7 100644 --- a/app/views/shared/_new_project_item_select.html.haml +++ b/app/views/shared/_new_project_item_select.html.haml @@ -1,6 +1,6 @@ - if @projects.any? .project-item-select-holder - = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at' } + = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at' }, with_feature_enabled: local_assigns[:with_feature_enabled] %a.btn.btn-new.new-project-item-select-button = local_assigns[:label] = icon('caret-down') diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml index ed6fc76c61e..b561e6dc248 100644 --- a/app/views/shared/_no_password.html.haml +++ b/app/views/shared/_no_password.html.haml @@ -1,8 +1,10 @@ - if cookies[:hide_no_password_message].blank? && !current_user.hide_no_password && current_user.require_password? .no-password-message.alert.alert-warning - You won't be able to pull or push project code via #{gitlab_config.protocol.upcase} until you #{link_to 'set a password', edit_profile_password_path} on your account + - set_password_link = link_to s_('SetPasswordToCloneLink|set a password'), edit_profile_password_path + - translation_params = { protocol: gitlab_config.protocol.upcase, set_password_link: set_password_link } + - set_password_message = _("You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account") % translation_params .alert-link-group - = link_to "Don't show again", profile_path(user: {hide_no_password: true}), method: :put + = link_to _("Don't show again"), profile_path(user: {hide_no_password: true}), method: :put | - = link_to 'Remind later', '#', class: 'hide-no-password-message' + = link_to _('Remind later'), '#', class: 'hide-no-password-message' diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml index d663fa13d10..e7815e28017 100644 --- a/app/views/shared/_no_ssh.html.haml +++ b/app/views/shared/_no_ssh.html.haml @@ -1,8 +1,9 @@ - if cookies[:hide_no_ssh_message].blank? && !current_user.hide_no_ssh_key && current_user.require_ssh_key? .no-ssh-key-message.alert.alert-warning - You won't be able to pull or push project code via SSH until you #{link_to 'add an SSH key', profile_keys_path, class: 'alert-link'} to your profile - + - add_ssh_key_link = link_to s_('MissingSSHKeyWarningLink|add an SSH key'), profile_keys_path, class: 'alert-link' + - ssh_message = _("You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile") % { add_ssh_key_link: add_ssh_key_link } + #{ ssh_message.html_safe } .alert-link-group - = link_to "Don't show again", profile_path(user: {hide_no_ssh_key: true}), method: :put, class: 'alert-link' + = link_to _("Don't show again"), profile_path(user: {hide_no_ssh_key: true}), method: :put, class: 'alert-link' | - = link_to 'Remind later', '#', class: 'hide-no-ssh-message alert-link' + = link_to _('Remind later'), '#', class: 'hide-no-ssh-message alert-link' diff --git a/app/views/shared/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml index 96f68c80c48..8b2a3bee407 100644 --- a/app/views/shared/_ref_dropdown.html.haml +++ b/app/views/shared/_ref_dropdown.html.haml @@ -1,6 +1,6 @@ - dropdown_class = local_assigns.fetch(:dropdown_class, '') -.dropdown-menu.dropdown-menu-selectable{ class: dropdown_class } +.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: dropdown_class } = dropdown_title "Select Git revision" = dropdown_filter "Filter by Git revision" = dropdown_content diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml index 9a8252ab087..d52bb6b4dd7 100644 --- a/app/views/shared/_ref_switcher.html.haml +++ b/app/views/shared/_ref_switcher.html.haml @@ -7,8 +7,8 @@ = 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), 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" + .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } + = dropdown_title _("Switch branch/tag") + = dropdown_filter _("Search branches and tags") = dropdown_content = dropdown_loading diff --git a/app/views/shared/_user_callout.html.haml b/app/views/shared/_user_callout.html.haml index 8308baa7829..17ffcba69d8 100644 --- a/app/views/shared/_user_callout.html.haml +++ b/app/views/shared/_user_callout.html.haml @@ -1,4 +1,4 @@ -.user-callout +.user-callout{ data: { uid: 'user_callout_dismissed' } } .bordered-box.landing.content-block %button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss customize experience box' } diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml new file mode 100644 index 00000000000..e6075c3ae3a --- /dev/null +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -0,0 +1,30 @@ +- form = local_assigns.fetch(:form) +- deploy_key = local_assigns.fetch(:deploy_key) + += form_errors(deploy_key) + +.form-group + = form.label :title, class: 'control-label' + .col-sm-10= form.text_field :title, class: 'form-control' + +.form-group + - if deploy_key.new_record? + = form.label :key, class: 'control-label' + .col-sm-10 + %p.light + Paste a machine public key here. Read more about how to generate it + = link_to 'here', help_page_path('ssh/README') + = form.text_area :key, class: 'form-control thin_area', rows: 5 + - else + = form.label :fingerprint, class: 'control-label' + .col-sm-10 + = form.text_field :fingerprint, class: 'form-control', readonly: 'readonly' + +.form-group + .control-label + .col-sm-10 + = form.label :can_push do + = form.check_box :can_push + %strong Write access allowed + %p.light.append-bottom-0 + Allow this key to push to repository as well? (Default only allows pull access.) diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index c229d18903f..046b127f73c 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -3,10 +3,10 @@ - has_button = button_path || project_select_button .row.empty-state - .pull-right.col-xs-12{ class: "#{'col-sm-6' if has_button}" } + .col-xs-12 .svg-content = render 'shared/empty_states/icons/issues.svg' - .col-xs-12{ class: "#{'col-sm-6' if has_button}" } + .col-xs-12.text-center .text-content - if has_button && current_user %h4 @@ -20,4 +20,3 @@ - else .text-center %h4 There are no issues to show. - = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link' diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml index 00fb77bdb3b..5e2f4cf109d 100644 --- a/app/views/shared/empty_states/_labels.html.haml +++ b/app/views/shared/empty_states/_labels.html.haml @@ -1,8 +1,8 @@ .row.empty-state.labels - .pull-right.col-xs-12.col-sm-6 + .col-xs-12 .svg-content = render 'shared/empty_states/icons/labels.svg' - .col-xs-12.col-sm-6 + .col-xs-12.text-center .text-content %h4 Labels can be applied to issues and merge requests to categorize them. %p You can also star a label to make it a priority label. diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml index 7f2f99f3406..3e64f403b8b 100644 --- a/app/views/shared/empty_states/_merge_requests.html.haml +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -3,10 +3,10 @@ - has_button = button_path || project_select_button .row.empty-state.merge-requests - .col-xs-12{ class: "#{'col-sm-6 pull-right' if has_button}" } + .col-xs-12 .svg-content = render 'shared/empty_states/icons/merge_requests.svg' - .col-xs-12{ class: "#{'col-sm-6' if has_button}" } + .col-xs-12.text-center .text-content - if has_button %h4 diff --git a/app/views/shared/empty_states/icons/_pipelines_empty.svg b/app/views/shared/empty_states/icons/_pipelines_empty.svg index 8119d5bebe0..7c672538097 100644 --- a/app/views/shared/empty_states/icons/_pipelines_empty.svg +++ b/app/views/shared/empty_states/icons/_pipelines_empty.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd" transform="translate(0-3)"><g transform="translate(0 105)"><g fill="#e5e5e5"><rect width="78" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g transform="translate(0 4)"><path fill="#98d7b2" fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path fill="#31af64" d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(69 3)"><path fill="#e5e5e5" fill-rule="nonzero" d="m4 11.99v60.02c0 4.413 3.583 7.99 8 7.99h89.991c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99m-4 0c0-6.622 5.378-11.99 12-11.99h89.991c6.629 0 12 5.367 12 11.99v60.02c0 6.622-5.378 11.99-12 11.99h-89.991c-6.629 0-12-5.367-12-11.99v-60.02m52.874 80.3l-13.253-15.292h34.76l-13.253 15.292c-2.237 2.582-6.01 2.585-8.253 0m3.02-2.62c.644.743 1.564.743 2.207 0l7.516-8.673h-17.24l7.516 8.673"/><rect width="18" height="6" x="15" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="39" y="39" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="33" y="55" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="39" y="23" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="57" y="55" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="15" y="55" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="81" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="15" y="39" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="57" y="23" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="69" y="23" rx="3"/><rect width="6" height="6" x="75" y="39" rx="3"/></g><rect width="6" height="6" x="63" y="39" fill="#e52c5a" rx="3"/></g><g transform="matrix(.70711-.70711.70711.70711 84.34 52.5)"><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26"/><path fill="#fff" fill-opacity=".3" stroke="#6b4fbb" stroke-width="8" d="m31 71c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30" transform="matrix(.86603.5-.5.86603 26.663-17.507)"/></g></g></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd"><g transform="translate(0 102)"><g fill="#e5e5e5"><rect width="74" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g fill="#31af64" transform="translate(0 4)"><path fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(73 4)"><path stroke="#e5e5e5" stroke-width="4" d="m64.82 76h33.18c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99v60.02c0 4.413 3.583 7.99 8 7.99h31.935l9.263 9.855c1.725 1.835 4.631 1.833 6.354 0l9.263-9.855"/><rect width="18" height="6" x="11" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="35" y="35" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="29" y="51" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="35" y="19" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="53" y="51" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="11" y="51" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="77" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="11" y="35" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="53" y="19" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="65" y="19" rx="3"/><rect width="6" height="6" x="71" y="35" rx="3"/></g><rect width="6" height="6" x="59" y="35" fill="#e52c5a" rx="3"/></g><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26" transform="matrix(.70711-.70711.70711.70711 84.34 49.5)"/></g></svg> diff --git a/app/views/shared/issuable/form/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 7ef0ae96be2..307d4919224 100644 --- a/app/views/shared/issuable/form/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -1,10 +1,11 @@ - project = local_assigns.fetch(:project) -- issuable = local_assigns.fetch(:issuable) +- model = local_assigns.fetch(:model) + - form = local_assigns.fetch(:form) -- supports_slash_commands = issuable.new_record? +- supports_slash_commands = model.new_record? - if supports_slash_commands - - preview_url = preview_markdown_path(project, slash_commands_target_type: issuable.class.name) + - preview_url = preview_markdown_path(project, slash_commands_target_type: model.class.name) - else - preview_url = preview_markdown_path(project) diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml new file mode 100644 index 00000000000..af6a499fadb --- /dev/null +++ b/app/views/shared/hook_logs/_content.html.haml @@ -0,0 +1,44 @@ +%p + %strong Request URL: + POST + = hook_log.url + = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } + +%p + %strong Trigger: + %td.hidden-xs + %span.label.label-gray.deploy-project-label + = hook_log.trigger.singularize.titleize +%p + %strong Elapsed time: + #{number_with_precision(hook_log.execution_duration, precision: 2)} ms +%p + %strong Request time: + = time_ago_with_tooltip(hook_log.created_at) + +%hr + +- if hook_log.internal_error_message.present? + .bs-callout.bs-callout-danger + = hook_log.internal_error_message + +%h5 Request headers: +%pre + - hook_log.request_headers.each do |k,v| + <strong>#{k}:</strong> #{v} + %br + +%h5 Request body: +%pre + :plain + #{JSON.pretty_generate(hook_log.request_data)} +%h5 Response headers: +%pre + - hook_log.response_headers.each do |k,v| + <strong>#{k}:</strong> #{v} + %br + +%h5 Response body: +%pre + :plain + #{hook_log.response_body} diff --git a/app/views/shared/hook_logs/_status_label.html.haml b/app/views/shared/hook_logs/_status_label.html.haml new file mode 100644 index 00000000000..b4ea8e6f952 --- /dev/null +++ b/app/views/shared/hook_logs/_status_label.html.haml @@ -0,0 +1,3 @@ +- label_status = hook_log.success? ? 'label-success' : 'label-danger' +%span{ class: "label #{label_status}" } + = hook_log.response_status diff --git a/app/views/shared/icons/_convdev_no_data.svg b/app/views/shared/icons/_convdev_no_data.svg new file mode 100644 index 00000000000..ed32b2333e7 --- /dev/null +++ b/app/views/shared/icons/_convdev_no_data.svg @@ -0,0 +1,40 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="360" height="220" viewBox="0 0 360 220"> + <g fill="none" fill-rule="evenodd"> + <path fill="#000" fill-opacity=".02" d="M125 44V24.003C125 18.48 129.483 14 135.005 14h89.99C230.52 14 235 18.477 235 24.003V43h84.992C326.624 43 332 48.372 332 55.002v144.996c0 6.63-5.38 12.002-12.008 12.002h-85.984c-6.632 0-12.008-5.372-12.008-12.002V183h-78v17.002c0 6.626-5.38 11.998-12.008 11.998H46.008C39.376 212 34 206.624 34 200.002V55.998C34 49.372 39.38 44 46.008 44H125z"/> + <g transform="translate(214 36)"> + <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/> + <path fill="#F0EDF8" fill-rule="nonzero" d="M57 111c-11.598 0-21-9.402-21-21s9.402-21 21-21 21 9.402 21 21-9.402 21-21 21zm0-4c9.39 0 17-7.61 17-17s-7.61-17-17-17-17 7.61-17 17 7.61 17 17 17z"/> + <path fill="#6B4FBB" d="M58 88v-6.997c0-1.11-.895-2.003-2-2.003-1.112 0-2 .897-2 2.003v8.994c0 1.11.895 2.003 2 2.003.174 0 .343-.022.503-.063.162.04.33.063.506.063h7.98C66.1 92 67 91.105 67 90c0-1.112-.9-2-2.01-2H58z"/> + <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/> + <path fill="#EEE" d="M21 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/> + </g> + <g transform="translate(118 7)"> + <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/> + <g fill-rule="nonzero"> + <path fill="#F0EDF8" d="M57 112c-12.15 0-22-9.85-22-22s9.85-22 22-22 22 9.85 22 22-9.85 22-22 22zm0-6c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16z"/> + <path fill="#6B4FBB" d="M41.692 105.8C45.768 109.75 51.21 112 57 112c12.15 0 22-9.85 22-22s-9.85-22-22-22v6c8.837 0 16 7.163 16 16s-7.163 16-16 16c-4.215 0-8.166-1.633-11.133-4.508l-4.175 4.31z"/> + </g> + <path fill="#EEE" d="M8 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2H9.998C8.895 18 8 17.112 8 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/> + </g> + <g transform="translate(26 36)"> + <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M4 12.006v147.988C4 164.42 7.58 168 12.005 168h89.99c4.42 0 8.005-3.586 8.005-8.006V12.006C110 7.58 106.42 4 101.995 4h-89.99C7.585 4 4 7.586 4 12.006zm-4 0C0 5.376 5.377 0 12.005 0h89.99C108.628 0 114 5.37 114 12.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C5.372 172 0 166.63 0 159.994V12.006z"/> + <g transform="translate(21 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/> + </g> + <g transform="translate(69 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/> + </g> + <g transform="translate(38 42)"> + <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/> + <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/> + </g> + <path fill="#EEE" d="M4 14h106v4H4z"/> + <path fill="#333" d="M35.724 138h9.696v-2.856h-2.856V122.76h-2.592c-1.08.648-2.136 1.08-3.792 1.392v2.184h2.856v8.808h-3.312V138zm17.736.288c-2.952 0-5.76-2.208-5.76-7.56 0-5.688 2.952-8.256 6.168-8.256 2.016 0 3.48.84 4.44 1.824l-1.848 2.112c-.528-.576-1.488-1.08-2.376-1.08-1.68 0-3.024 1.2-3.144 4.752.792-1.008 2.112-1.608 3.048-1.608 2.616 0 4.536 1.488 4.536 4.704 0 3.168-2.304 5.112-5.064 5.112zm-.072-2.64c1.056 0 1.92-.744 1.92-2.472 0-1.608-.84-2.208-1.992-2.208-.792 0-1.68.432-2.304 1.512.312 2.4 1.32 3.168 2.376 3.168zM63.9 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/> + </g> + </g> +</svg> diff --git a/app/views/shared/icons/_convdev_no_index.svg b/app/views/shared/icons/_convdev_no_index.svg new file mode 100644 index 00000000000..95c00e81d10 --- /dev/null +++ b/app/views/shared/icons/_convdev_no_index.svg @@ -0,0 +1,67 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200"> + <g fill="none" fill-rule="evenodd" transform="translate(3 11)"> + <rect width="110" height="168" x="6" y="8" fill="#000" fill-opacity=".02" rx="10"/> + <g transform="translate(0 2)"> + <rect width="110" height="168" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M2 10.006v147.988C2 162.42 5.58 166 10.005 166h89.99c4.42 0 8.005-3.586 8.005-8.006V10.006C108 5.58 104.42 2 99.995 2h-89.99C5.585 2 2 5.586 2 10.006zm-4 0C-2 3.376 3.377-2 10.005-2h89.99C106.628-2 112 3.37 112 10.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C3.372 170-2 164.63-2 157.994V10.006z"/> + <g transform="translate(19 80)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/> + </g> + <g transform="translate(67 80)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/> + </g> + <g transform="translate(36 40)"> + <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/> + <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/> + </g> + <path fill="#EEE" d="M2 12h106v4H2z"/> + <path fill="#333" d="M38.048 127.792c.792 0 1.68-.432 2.28-1.512-.312-2.4-1.296-3.168-2.376-3.168-1.032 0-1.92.744-1.92 2.472 0 1.608.864 2.208 2.016 2.208zm-.552 8.496c-2.016 0-3.504-.864-4.464-1.824l1.872-2.112c.504.576 1.464 1.08 2.352 1.08 1.704 0 3.024-1.2 3.144-4.752-.792 1.008-2.112 1.608-3.048 1.608-2.592 0-4.536-1.488-4.536-4.704 0-3.168 2.304-5.112 5.064-5.112 2.952 0 5.784 2.208 5.784 7.56 0 5.688-2.976 8.256-6.168 8.256zm13.488 0c-3.048 0-5.304-1.704-5.304-4.176 0-1.848 1.152-2.976 2.592-3.744v-.096c-1.176-.888-2.04-1.992-2.04-3.6 0-2.592 2.04-4.2 4.872-4.2 2.784 0 4.632 1.656 4.632 4.176 0 1.464-.936 2.64-1.992 3.336v.096c1.464.792 2.64 1.968 2.64 3.984 0 2.4-2.16 4.224-5.4 4.224zm.96-9.168c.6-.696.936-1.44.936-2.232 0-1.176-.696-1.968-1.848-1.968-.936 0-1.704.576-1.704 1.752 0 1.248 1.056 1.848 2.616 2.448zm-.888 6.72c1.176 0 2.04-.624 2.04-1.896 0-1.344-1.296-1.848-3.216-2.664-.672.624-1.176 1.488-1.176 2.424 0 1.344 1.08 2.136 2.352 2.136zm10.8-3.84c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/> + </g> + <g transform="translate(122)"> + <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/> + <g transform="translate(21 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/> + </g> + <g transform="translate(69 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/> + </g> + <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/> + <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/> + <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/> + </g> + <g transform="translate(243)"> + <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/> + <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/> + <g transform="translate(21 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/> + </g> + <g transform="translate(69 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/> + </g> + <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/> + <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/> + </g> + </g> +</svg> diff --git a/app/views/shared/icons/_convdev_overview.svg b/app/views/shared/icons/_convdev_overview.svg new file mode 100644 index 00000000000..2f31113bad7 --- /dev/null +++ b/app/views/shared/icons/_convdev_overview.svg @@ -0,0 +1,64 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="208" height="127" viewBox="0 0 208 127" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <rect id="a" width="58" height="98" y="17" rx="6"/> + <rect id="b" width="58" height="98" x="3.5" y="17" rx="6"/> + <rect id="c" width="58" height="98.394" rx="6"/> + </defs> + <g fill="none" fill-rule="evenodd" transform="translate(1)"> + <path fill="#000" fill-opacity=".06" fill-rule="nonzero" d="M16 11.06c0-1.39.56-2.69 1.534-3.635.398-.386.41-1.025.027-1.426-.382-.402-1.015-.414-1.413-.028C14.785 7.294 14 9.116 14 11.062c0 .556.448 1.007 1 1.007s1-.452 1-1.01zm6.432-5.043h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0H185c1.21 0 2.354.435 3.254 1.215.42.362 1.05.314 1.41-.108.36-.423.312-1.06-.107-1.422C188.297 4.612 186.694 4 185 4h-.568c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zM190 11.932v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm0 10.89v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.84c0 .555.448 1.007 1 1.007s1-.453 1-1.01v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008V44.6c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01zm0 10.888v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.007zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm-.24 21.446c-.42 1.304-1.353 2.385-2.572 2.985-.497.244-.703.847-.46 1.348.24.5.84.708 1.336.464 1.707-.84 3.013-2.35 3.598-4.178.17-.53-.12-1.098-.644-1.27-.526-.17-1.09.12-1.26.65zm-8.063 3.49h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.577-.116c-1.33-.295-2.48-1.13-3.19-2.3-.287-.474-.902-.623-1.373-.333-.472.29-.62.91-.332 1.386.99 1.632 2.6 2.8 4.465 3.215.54.12 1.073-.224 1.192-.768.12-.544-.222-1.082-.762-1.2zM16 105.292v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01zm0-10.89v-4.84c0-.555-.448-1.007-1-1.007s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.007zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01zm0-11.888v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-9.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.007v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01z"/> + <g transform="translate(74)"> + <rect width="58" height="98" y="20" fill="#000" fill-opacity=".02" rx="6"/> + <use fill="#FFF" xlink:href="#a"/> + <rect width="56" height="96" x="1" y="18" stroke="#EEE" stroke-width="2" rx="6"/> + <g transform="translate(16 45.185)"> + <path fill="#333" d="M.59 33.815h5.655V32.15H4.58v-7.225H3.066c-.63.378-1.246.63-2.212.812v1.274H2.52v5.14H.59v1.665zm10.093.168c-1.778 0-3.094-.994-3.094-2.436 0-1.078.67-1.736 1.51-2.184v-.056c-.685-.518-1.19-1.162-1.19-2.1 0-1.512 1.19-2.45 2.843-2.45 1.624 0 2.702.966 2.702 2.436 0 .854-.546 1.54-1.162 1.946v.055c.854.462 1.54 1.148 1.54 2.324 0 1.4-1.26 2.463-3.15 2.463zm.56-5.348c.35-.406.546-.84.546-1.302 0-.686-.407-1.148-1.08-1.148-.545 0-.993.336-.993 1.022 0 .728.616 1.078 1.526 1.428zm-.518 3.92c.686 0 1.19-.364 1.19-1.106 0-.785-.756-1.08-1.876-1.555-.393.364-.687.868-.687 1.414 0 .783.63 1.245 1.372 1.245zm6.3-2.24c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.063 2.282 2.883 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.463h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.883 2.27-2.883 1.315 0 2.28 1.064 2.28 2.884 0 1.835-.965 2.913-2.28 2.913zm0-1.148c.46 0 .84-.462.84-1.764 0-1.3-.38-1.735-.84-1.735-.463 0-.84.434-.84 1.736 0 1.303.377 1.765.84 1.765z"/> + <rect width="13" height="2" x="6" y=".815" fill="#FB722E" rx="1"/> + <path fill="#F0EDF8" d="M3 47.815c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/> + <rect width="20" height="2" x="3" y="6.815" fill="#FEE1D3" rx="1"/> + </g> + <g transform="translate(10.81)"> + <circle cx="18.19" cy="18" r="18" fill="#FFF"/> + <path fill="#F0EDF8" fill-rule="nonzero" d="M18.19 34c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16zm0 2c-9.94 0-18-8.06-18-18s8.06-18 18-18 18 8.06 18 18-8.06 18-18 18z"/> + <g transform="translate(10 11)"> + <path fill="#C3B8E3" fill-rule="nonzero" d="M2.19 13.32L5.397 11h7.783c.566 0 1.01-.444 1.01-1V3c0-.55-.45-1-1.01-1H3.2c-.566 0-1.01.444-1.01 1v10.32zM6.045 13l-3.422 2.476C1.28 16.45.19 15.892.19 14.23V3c0-1.657 1.337-3 3.01-3h9.98c1.663 0 3.01 1.342 3.01 3v7c0 1.657-1.337 3-3.01 3H6.045z"/> + <rect width="4" height="2" x="5.19" y="4" fill="#6B4FBB" rx="1"/> + <rect width="6" height="2" x="5.19" y="7" fill="#6B4FBB" rx="1"/> + </g> + </g> + </g> + <g transform="translate(144.5)"> + <rect width="58" height="98" x=".5" y="20" fill="#000" fill-opacity=".02" rx="6"/> + <use fill="#FFF" xlink:href="#b"/> + <rect width="56" height="96" x="4.5" y="18" stroke="#EEE" stroke-width="2" rx="6"/> + <g transform="translate(19 46.185)"> + <path fill="#333" d="M4.01 33.746c1.793 0 3.305-.938 3.305-2.59 0-1.148-.742-1.876-1.764-2.17v-.056c.953-.406 1.485-1.05 1.485-1.974 0-1.554-1.232-2.436-3.066-2.436-1.093 0-1.99.434-2.8 1.134l1.035 1.26c.56-.49 1.036-.784 1.666-.784.7 0 1.093.364 1.093.98 0 .714-.504 1.19-2.1 1.19v1.456c1.932 0 2.394.49 2.394 1.274 0 .672-.574 1.05-1.442 1.05-.756 0-1.414-.378-1.946-.896l-.953 1.302c.644.756 1.652 1.26 3.094 1.26zm4.51-.168h6.257v-1.736h-1.792c-.42 0-1.036.056-1.484.112 1.443-1.512 2.843-3.108 2.843-4.606 0-1.708-1.19-2.828-2.94-2.828-1.274 0-2.1.476-2.982 1.414l1.12 1.106c.45-.476.94-.91 1.583-.91.77 0 1.26.476 1.26 1.344 0 1.26-1.596 2.786-3.864 4.928v1.176zm9.505-3.5c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.064 2.282 2.884 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.464h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.064 2.28 2.884 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/> + <rect width="13" height="2.008" x="7.5" fill="#FB722E" rx="1.004"/> + <path fill="#F0EDF8" d="M3.5 47.19c0-.556.455-1.005 1.006-1.005h17.988c.556 0 1.006.445 1.006 1.004 0 .553-.455 1.003-1.006 1.003H4.506c-.556 0-1.006-.446-1.006-1.004zm0 6.023c0-.555.455-1.004 1.006-1.004h17.988c.556 0 1.006.444 1.006 1.003 0 .554-.455 1.004-1.006 1.004H4.506c-.556 0-1.006-.446-1.006-1.004z"/> + <rect width="20" height="2.008" x="4" y="6.024" fill="#FEE1D3" rx="1.004"/> + </g> + <g transform="translate(14.413)"> + <circle cx="18.087" cy="18" r="18" fill="#FFF"/> + <path fill="#F0EDF8" fill-rule="nonzero" d="M18.087 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/> + <path fill="#C3B8E3" fill-rule="nonzero" d="M18.087 24c3.313 0 6-2.686 6-6s-2.687-6-6-6c-3.314 0-6 2.686-6 6s2.686 6 6 6zm0 2c-4.42 0-8-3.582-8-8s3.58-8 8-8c4.418 0 8 3.582 8 8s-3.582 8-8 8z"/> + <path fill="#6B4FBB" d="M19.087 17v-2c0-.556-.448-1-1-1-.557 0-1 .448-1 1v3c0 .278.11.528.292.71.18.18.43.29.706.29h3c.557 0 1-.448 1-1 0-.556-.447-1-1-1h-2z"/> + </g> + </g> + <rect width="58" height="98" x="3" y="20" fill="#000" fill-opacity=".02" rx="6"/> + <g transform="translate(0 16.754)"> + <use fill="#FFF" xlink:href="#c"/> + <rect width="56" height="96.394" x="1" y="1" stroke="#EEE" stroke-width="2" rx="6"/> + <g transform="translate(16 29.618)"> + <path fill="#333" d="M3.137 27.84c.462 0 .98-.253 1.33-.883-.182-1.4-.756-1.848-1.386-1.848-.6 0-1.12.433-1.12 1.44 0 .94.505 1.29 1.177 1.29zm-.322 4.955C1.64 32.795.77 32.29.21 31.73l1.093-1.23c.294.335.854.63 1.372.63.994 0 1.764-.7 1.834-2.773-.463.588-1.233.938-1.78.938-1.51 0-2.645-.868-2.645-2.744 0-1.847 1.344-2.98 2.954-2.98 1.72 0 3.373 1.287 3.373 4.41 0 3.317-1.736 4.815-3.598 4.815zm8.12 0c-1.722 0-3.36-1.288-3.36-4.41 0-3.318 1.722-4.816 3.598-4.816 1.176 0 2.03.49 2.59 1.063l-1.078 1.232c-.308-.336-.868-.63-1.386-.63-.98 0-1.765.7-1.835 2.772.462-.588 1.232-.938 1.778-.938 1.526 0 2.646.867 2.646 2.743 0 1.848-1.345 2.982-2.955 2.982zm-.042-1.54c.616 0 1.12-.434 1.12-1.442 0-.938-.49-1.288-1.162-1.288-.46 0-.98.252-1.343.882.182 1.4.77 1.848 1.386 1.848zm6.132-2.128c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.065 2.282 2.885 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.463.84-1.765 0-1.302-.378-1.736-.84-1.736-.462 0-.84.433-.84 1.735s.378 1.764.84 1.764zm.308 4.815l4.928-9.464h1.19l-4.927 9.465h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.063 2.28 2.883 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/> + <rect width="13" height="2.008" x="6.5" y=".314" fill="#FEE1D3" rx="1.004"/> + <path fill="#F0EDF8" d="M3 46.627c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/> + <rect width="20" height="2" x="3" y="5.627" fill="#FB722E" rx="1"/> + </g> + </g> + <g transform="translate(10.41)"> + <circle cx="18.589" cy="18" r="18" fill="#FFF"/> + <path fill="#F0EDF8" fill-rule="nonzero" d="M18.59 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/> + <path fill="#C3B8E3" d="M17.05 19.262h3.367l.248-2.808H17.3l-.25 2.808zm-.177 2.008l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627H13.59c-.554 0-1.003-.446-1.003-1.004 0-.555.455-1.004 1.002-1.004h1.325l.248-2.808h-1.15c-.555 0-1.004-.445-1.004-1.004 0-.554.457-1.004 1.004-1.004h1.33l.106-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h3.365l.107-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h1.03c.554 0 1.003.446 1.003 1.004 0 .555-.455 1.004-1 1.004H22.8l-.25 2.808h1.037c.554 0 1.002.446 1.002 1.004 0 .554-.456 1.004-1.003 1.004h-1.214l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627h-3.367z"/> + <path fill="#6B4FBB" d="M17.05 19.262l-.177 2.008H14.74l.177-2.008h2.134zm-1.707-4.816h2.135l-.178 2.008h-2.135l.178-2.008zm5.5 0h2.135l-.178 2.008h-2.135l.178-2.008zm1.708 4.816l-.177 2.008H20.24l.177-2.008h2.134z"/> + </g> + </g> +</svg> diff --git a/app/views/shared/icons/_i2p_step_1.svg b/app/views/shared/icons/_i2p_step_1.svg new file mode 100644 index 00000000000..9dedcd5291a --- /dev/null +++ b/app/views/shared/icons/_i2p_step_1.svg @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m45.688 18.854c-4.869-1.989-10.488-1.975-15.29-.001-2.413.979-4.597 2.414-6.493 4.268-1.836 1.8-3.33 3.985-4.346 6.381-1.013 2.38-1.525 4.916-1.525 7.537 0 2.066.33 4.118.983 6.104.469 1.388 1.089 2.706 1.83 3.937-1.275 1.101-2.086 2.725-2.086 4.538 0 3.309 2.691 6 6 6s6-2.691 6-6-2.691-6-6-6c-.779 0-1.522.154-2.205.425-.665-1.105-1.221-2.289-1.642-3.533-.585-1.776-.881-3.618-.881-5.472 0-2.351.459-4.623 1.391-6.814.89-2.096 2.231-4.059 3.88-5.675 1.708-1.669 3.675-2.962 5.85-3.845 4.329-1.778 9.392-1.79 13.78.002 2.17.881 4.137 2.175 5.843 3.84 3.39 3.34 5.257 7.776 5.257 12.493.002 1.86-.294 3.705-.878 5.481-.579 1.75-1.443 3.406-2.569 4.923-2.134 2.866-3.818 4.698-5.174 6.173-2.424 2.643-3.98 4.599-4.383 8.384h-10.815c-.553 0-1 .447-1 1s.447 1 1 1h11.739c.532 0 .971-.416.999-.947.19-3.645 1.345-5.263 3.934-8.09 1.385-1.506 3.107-3.381 5.304-6.331 1.254-1.688 2.218-3.535 2.864-5.489.651-1.98.98-4.04.979-6.109 0-5.256-2.078-10.198-5.856-13.92-1.897-1.851-4.081-3.287-6.49-4.265m-16.927 32.763c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/> + <path d="m40 74h-4c-.553 0-1 .447-1 1s.447 1 1 1h4c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m42 70h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m38 10c.553 0 1-.447 1-1v-8c0-.553-.447-1-1-1s-1 .447-1 1v8c0 .553.447 1 1 1"/> + <path d="m20.828 15.828c.256 0 .512-.098.707-.293.391-.391.391-1.023 0-1.414l-5.656-5.656c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l5.656 5.656c.195.195.451.293.707.293"/> + <path d="m10 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m60.12 8.465l-5.656 5.656c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5.656-5.656c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0"/> + <path d="m74 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m43 66h-10c-.553 0-1 .447-1 1s.447 1 1 1h10c.553 0 1-.447 1-1s-.447-1-1-1"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_10.svg b/app/views/shared/icons/_i2p_step_10.svg new file mode 100644 index 00000000000..dd6fd1457ff --- /dev/null +++ b/app/views/shared/icons/_i2p_step_10.svg @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m5 43c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4"/> + <path d="m75 37h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m21 38c0 .345.178.665.47.848l8 5c.165.103.348.152.529.152.333 0 .659-.166.849-.47.293-.469.15-1.086-.317-1.378l-6.644-4.152 6.644-4.152c.468-.292.61-.909.317-1.378s-.908-.611-1.378-.317l-8 5c-.292.182-.47.502-.47.847"/> + <path d="m55 38c0-.345-.178-.665-.47-.848l-8-5c-.469-.294-1.086-.151-1.378.317-.293.469-.15 1.086.317 1.378l6.644 4.153-6.644 4.152c-.468.292-.61.909-.317 1.378.189.304.516.47.849.47.181 0 .364-.049.529-.152l8-5c.292-.183.47-.503.47-.848"/> + <path d="m41.803 26.05c-.525-.168-1.089.124-1.256.65l-7 22c-.167.525.124 1.088.65 1.256.101.032.202.047.303.047.424 0 .817-.271.953-.697l7-22c.167-.526-.124-1.088-.65-1.256"/> + <path d="m62 7c3.859 0 7 3.141 7 7v11c0 .553.447 1 1 1s1-.447 1-1v-11c0-4.963-4.04-9-9-9h-16.09c-.479-2.833-2.943-5-5.91-5-3.309 0-6 2.691-6 6s2.691 6 6 6c2.967 0 5.431-2.167 5.91-5h16.09m-22 3c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/> + <path d="m6 26c.553 0 1-.447 1-1v-11c0-3.859 3.141-7 7-7h11.09l-3.293 3.293c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5-5c.391-.391.391-1.023 0-1.414l-5-5c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l3.293 3.293h-11.09c-4.963 0-9 4.04-9 9v11c0 .553.447 1 1 1"/> + <path d="m36 64c-2.967 0-5.431 2.167-5.91 5h-16.09c-3.859 0-7-3.141-7-7v-11c0-.553-.447-1-1-1s-1 .447-1 1v11c0 4.963 4.04 9 9 9h16.09c.478 2.833 2.942 5 5.91 5 3.309 0 6-2.691 6-6s-2.691-6-6-6m0 10c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/> + <path d="m70 50c-.553 0-1 .447-1 1v11c0 3.859-3.141 7-7 7h-11.09l3.293-3.293c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0l-5 5c-.391.391-.391 1.023 0 1.414l5 5c.195.195.451.293.707.293s.512-.098.707-.293c.391-.391.391-1.023 0-1.414l-3.293-3.293h11.09c4.963 0 9-4.04 9-9v-11c0-.553-.447-1-1-1"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_2.svg b/app/views/shared/icons/_i2p_step_2.svg new file mode 100644 index 00000000000..b8805b90275 --- /dev/null +++ b/app/views/shared/icons/_i2p_step_2.svg @@ -0,0 +1,5 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m42.26 40.44c.558.073 1.045-.329 1.109-.877l2.625-22.444c.033-.283-.057-.567-.246-.781-.189-.214-.462-.336-.747-.336h-14c-.284 0-.555.121-.744.332-.19.212-.281.494-.25.776l3.454 31.575c-1.503 1.285-2.46 3.19-2.46 5.317 0 3.859 3.141 7 7 7s7-3.141 7-7-3.141-7-7-7c-.94 0-1.835.189-2.655.527l-3.23-29.527h11.761l-2.494 21.328c-.065.549.328 1.045.877 1.11m.741 13.562c0 2.757-2.243 5-5 5s-5-2.243-5-5 2.243-5 5-5 5 2.243 5 5"/> + <path d="M73.236,23.749c-0.207-0.513-0.796-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_3.svg b/app/views/shared/icons/_i2p_step_3.svg new file mode 100644 index 00000000000..6c783ed8289 --- /dev/null +++ b/app/views/shared/icons/_i2p_step_3.svg @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m12 8c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909 0 3.309 2.691 6 6 6s6-2.691 6-6c0-2.967-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.479 5-2.943 5-5.91m-10 0c0-2.206 1.794-4 4-4s4 1.794 4 4-1.794 4-4 4-4-1.794-4-4m8 60c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/> + <path d="m21 6h54c.553 0 1-.447 1-1s-.447-1-1-1h-54c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m21 12h35c.553 0 1-.447 1-1s-.447-1-1-1h-35c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m75 24h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m21 32h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m75 44h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m21 52h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m75 64h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m55 70h-34c-.553 0-1 .447-1 1s.447 1 1 1h34c.553 0 1-.447 1-1s-.447-1-1-1"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_4.svg b/app/views/shared/icons/_i2p_step_4.svg new file mode 100644 index 00000000000..af804c838e0 --- /dev/null +++ b/app/views/shared/icons/_i2p_step_4.svg @@ -0,0 +1,6 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m67.7 10h-6.751c-.507-5.598-5.221-10-10.949-10-6.06 0-11 4.935-11 11s4.935 11 11 11c5.728 0 10.442-4.402 10.949-10h6.751c1.269 0 2.3.987 2.3 2.2v57.6c0 1.213-1.031 2.2-2.3 2.2h-59.4c-1.269 0-2.3-.987-2.3-2.2v-57.6c0-1.213 1.031-2.2 2.3-2.2h15.15c.553 0 1-.447 1-1s-.447-1-1-1h-15.15c-2.371 0-4.3 1.884-4.3 4.2v57.6c0 2.316 1.929 4.2 4.3 4.2h59.4c2.371 0 4.3-1.884 4.3-4.2v-57.6c0-2.316-1.929-4.2-4.3-4.2m-17.7 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/> + <path d="m21.293 29.29c-.391.391-.391 1.023 0 1.414l12.975 12.975-12.975 12.974c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l13.682-13.682c.391-.391.391-1.023 0-1.414l-13.682-13.681c-.391-.391-1.023-.391-1.414 0"/> + <path d="m54 59c.553 0 1-.447 1-1s-.447-1-1-1h-12c-.553 0-1 .447-1 1s.447 1 1 1h12"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_5.svg b/app/views/shared/icons/_i2p_step_5.svg new file mode 100644 index 00000000000..e54f707019e --- /dev/null +++ b/app/views/shared/icons/_i2p_step_5.svg @@ -0,0 +1,5 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m48.949 37c-.507-5.598-5.221-10-10.949-10s-10.442 4.402-10.949 10h-13.05c-.553 0-1 .447-1 1s.447 1 1 1h13.05c.507 5.598 5.221 10 10.949 10s10.442-4.402 10.949-10h12.24c.553 0 1-.447 1-1s-.447-1-1-1h-12.24m-10.949 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/> + <path d="M73.236,23.749c-0.207-0.513-0.797-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_6.svg b/app/views/shared/icons/_i2p_step_6.svg new file mode 100644 index 00000000000..c57baccc06b --- /dev/null +++ b/app/views/shared/icons/_i2p_step_6.svg @@ -0,0 +1,15 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m14.267 7.32l-4.896 5.277-1.702-1.533c-.409-.369-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/> + <path d="m31 9h44c.553 0 1-.447 1-1s-.447-1-1-1h-44c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m31 15h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m11 0c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/> + <path d="m14.267 34.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/> + <path d="m75 34h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m31 42h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m11 27c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/> + <path d="m14.267 61.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/> + <path d="m11 54c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/> + <path d="m75 61h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m55 67h-24c-.553 0-1 .447-1 1s.447 1 1 1h24c.553 0 1-.447 1-1s-.447-1-1-1"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_7.svg b/app/views/shared/icons/_i2p_step_7.svg new file mode 100644 index 00000000000..e9083de3afa --- /dev/null +++ b/app/views/shared/icons/_i2p_step_7.svg @@ -0,0 +1,7 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="M73.236,23.749c-0.208-0.513-0.798-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/> + <path d="m27.19 32.17c-.277-.479-.89-.643-1.366-.364l-12.654 7.326c-.309.179-.499.509-.499.865s.19.687.499.865l12.654 7.326c.157.092.33.135.5.135.345 0 .681-.179.866-.499.277-.478.113-1.09-.364-1.366l-11.159-6.461 11.159-6.461c.478-.276.642-.889.364-1.366"/> + <path d="m48.808 47.827c.186.32.521.499.866.499.17 0 .343-.043.5-.135l12.654-7.326c.309-.179.499-.509.499-.865s-.19-.687-.499-.865l-12.654-7.326c-.478-.278-1.09-.114-1.366.364-.277.478-.113 1.09.364 1.366l11.159 6.461-11.159 6.461c-.478.276-.642.889-.364 1.366"/> + <path d="m42.71 23.06l-11.312 33.23c-.179.522.102 1.091.624 1.269.106.037.216.054.322.054.416 0 .805-.262.946-.678l11.312-33.23c.179-.522-.102-1.091-.624-1.269-.523-.181-1.089.101-1.268.624"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_8.svg b/app/views/shared/icons/_i2p_step_8.svg new file mode 100644 index 00000000000..62676b0e12e --- /dev/null +++ b/app/views/shared/icons/_i2p_step_8.svg @@ -0,0 +1,4 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m62.44 54.765l-9.912-11.09c.315-3.881.481-7.241.508-10.271-.029-13.871-3.789-23.05-13.413-32.746-.855-.859-2.411-.828-3.294.059-7.594 7.65-11.139 13.934-12.575 22.3-1.776.062-3.437.776-4.699 2.039-1.321 1.321-2.05 3.079-2.05 4.949s.729 3.628 2.051 4.949c1.321 1.322 3.079 2.051 4.949 2.051s3.628-.729 4.949-2.051c1.322-1.321 2.051-3.079 2.051-4.949 0-1.869-.729-3.627-2.051-4.949-.9-.9-2-1.517-3.205-1.824 1.373-7.859 4.764-13.818 11.999-21.11.128-.13.356-.158.456-.059 9.207 9.274 12.805 18.06 12.832 31.33-.026 3.079-.202 6.527-.536 10.54-.023.273.067.545.25.749l10.166 11.379c.062.076.109.23.093.32l-4.547 17.407c-.004.015-.009.036-.079.106-.062.063-.155.1-.2.106l-3.577.002c-.144-.009-.265-.077-.309-.153l-5.425-10.328c-.173-.329-.515-.535-.886-.535h-15.962c-.371 0-.713.206-.886.535l-5.407 10.303-.069.072c-.07.07-.165.105-.199.105l-3.588.001c-.179-.009-.304-.123-.33-.227l-4.531-17.338c-.029-.146.019-.301.049-.34l10.197-11.415c.367-.412.332-1.044-.08-1.412-.411-.366-1.042-.333-1.412.08l-10.229 11.453c-.448.554-.63 1.312-.474 2.084l4.544 17.396c.253.963 1.146 1.669 2.218 1.719h3.636c.581 0 1.187-.261 1.615-.693.114-.114.286-.286.406-.528l5.144-9.793h14.754l5.16 9.822c.396.697 1.124 1.143 2.01 1.192l3.712-.003c.604-.046 1.137-.285 1.544-.694.313-.316.504-.646.598-1.022l4.557-17.451c.143-.718-.039-1.476-.518-2.066m-33.435-24.765c0 1.335-.521 2.591-1.465 3.535s-2.2 1.465-3.535 1.465-2.591-.521-3.535-1.465-1.465-2.2-1.465-3.535.521-2.591 1.465-3.535 2.2-1.465 3.535-1.465 2.591.521 3.535 1.465 1.465 2.2 1.465 3.535"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_9.svg b/app/views/shared/icons/_i2p_step_9.svg new file mode 100644 index 00000000000..e4285a14425 --- /dev/null +++ b/app/views/shared/icons/_i2p_step_9.svg @@ -0,0 +1,4 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m68 67c-1.725 0-3.36.541-4.723 1.545-2.298-4.02-6.592-6.545-11.277-6.545-2.734 0-5.359.853-7.555 2.43l-2.286-15.43h1.228l3.829 7.645c.339.598.962.979 1.724 1.022l2.812-.003c.507-.039.974-.25 1.316-.595.264-.266.433-.559.514-.882l3.433-13.145c.12-.611-.037-1.258-.449-1.763l-7.385-8.268c.231-2.875.354-5.376.374-7.641-.023-10.507-2.871-17.462-10.162-24.806-.737-.742-2.072-.715-2.829.044-5.617 5.659-8.309 10.336-9.446 16.463-1.267.186-2.438.764-3.36 1.686-1.134 1.134-1.758 2.64-1.758 4.243s.624 3.109 1.758 4.242c1.133 1.134 2.639 1.758 4.242 1.758s3.109-.624 4.242-1.758c1.134-1.133 1.758-2.639 1.758-4.242s-.624-3.109-1.758-4.242c-.858-.859-1.932-1.424-3.098-1.648 1.095-5.538 3.637-9.855 8.83-15.14 6.874 6.924 9.561 13.485 9.581 23.392-.021 2.316-.151 4.903-.402 7.91-.023.273.067.544.25.749l7.663 8.572-3.391 13.07-2.695.036-4.081-8.15c-.17-.339-.516-.553-.895-.553h-12.01c-.379 0-.725.214-.895.553l-4.04 8.114-2.707.015-3.427-13.07 7.671-8.588c.367-.412.332-1.044-.08-1.412-.411-.366-1.043-.333-1.412.08l-7.7 8.623c-.383.47-.54 1.116-.406 1.787l3.419 13.08c.216.829.98 1.438 1.907 1.48h2.735c.508 0 1.016-.218 1.391-.595.091-.09.242-.241.358-.475l3.804-7.597h1.228l-2.286 15.43c-2.196-1.577-4.821-2.43-7.555-2.43-4.685 0-8.979 2.53-11.277 6.545-1.363-1-2.998-1.545-4.723-1.545-4.411 0-8 3.589-8 8 0 .553.447 1 1 1h74c.553 0 1-.447 1-1 0-4.411-3.589-8-8-8m-36-44c0 1.068-.416 2.072-1.172 2.828-1.512 1.512-4.145 1.512-5.656 0-.756-.756-1.172-1.76-1.172-2.828s.416-2.072 1.172-2.828 1.76-1.172 2.828-1.172 2.072.416 2.828 1.172 1.172 1.76 1.172 2.828m-29.917 51c.478-2.834 2.949-5 5.917-5 1.638 0 3.17.652 4.313 1.836.231.24.562.35.895.29.327-.058.604-.274.739-.579 1.765-3.977 5.711-6.547 10.05-6.547 2.836 0 5.532 1.085 7.593 3.055.271.258.665.345 1.016.224.354-.122.61-.43.665-.8l2.588-17.479h4.275l2.589 17.479c.055.37.312.678.665.8s.745.035 1.016-.224c2.061-1.97 4.757-3.055 7.593-3.055 4.343 0 8.288 2.57 10.05 6.547.135.305.412.521.739.579.329.059.663-.051.895-.29 1.143-1.184 2.675-1.836 4.313-1.836 2.968 0 5.439 2.166 5.917 5h-71.834"/> +</svg> diff --git a/app/views/shared/icons/_icon_history.svg b/app/views/shared/icons/_icon_history.svg new file mode 100644 index 00000000000..41096da19c5 --- /dev/null +++ b/app/views/shared/icons/_icon_history.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1792" height="1792" viewBox="0 0 1792 1792"><path d="M1664 896q0 156-61 298t-164 245-245 164-298 61q-172 0-327-72.5T305 1387q-7-10-6.5-22.5t8.5-20.5l137-138q10-9 25-9 16 2 23 12 73 95 179 147t225 52q104 0 198.5-40.5T1258 1258t109.5-163.5T1408 896t-40.5-198.5T1258 534t-163.5-109.5T896 384q-98 0-188 35.5T548 521l137 138q31 30 14 69-17 40-59 40H192q-26 0-45-19t-19-45V256q0-42 40-59 39-17 69 14l130 129q107-101 244.5-156.5T896 128q156 0 298 61t245 164 164 245 61 298zm-640-288v448q0 14-9 23t-23 9H672q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224V608q0-14 9-23t23-9h64q14 0 23 9t9 23z"/></svg> diff --git a/app/views/shared/icons/_icon_status_skipped.svg b/app/views/shared/icons/_icon_status_skipped.svg index 1998dfef9ea..a9ba29c922c 100755 --- a/app/views/shared/icons/_icon_status_skipped.svg +++ b/app/views/shared/icons/_icon_status_skipped.svg @@ -1 +1 @@ -<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M7.69 7.7l-.905.905a.7.7 0 0 0 .99.99l1.85-1.85c.411-.412.411-1.078 0-1.49l-1.85-1.85a.7.7 0 0 0-.99.99l.905.905H4.48a.7.7 0 0 0 0 1.4h3.21z"/></g></svg> +<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M7 14A7 7 0 1 1 7 0a7 7 0 0 1 0 14z"/><path d="M7 13A6 6 0 1 0 7 1a6 6 0 0 0 0 12z" fill="#FFF" fill-rule="nonzero"/><path d="M6.415 7.04L4.579 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L5.341 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L6.415 7.04zm2.54 0L7.119 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L7.881 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L8.955 7.04z"/></svg> diff --git a/app/views/shared/icons/_icon_status_skipped_borderless.svg b/app/views/shared/icons/_icon_status_skipped_borderless.svg index fb3e930b3cb..3c8a26d7f4d 100644 --- a/app/views/shared/icons/_icon_status_skipped_borderless.svg +++ b/app/views/shared/icons/_icon_status_skipped_borderless.svg @@ -1 +1 @@ -<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12.0846,12.1 L10.6623,13.5223 C10.2454306,13.9539168 10.2513924,14.6399933 10.6756996,15.0643004 C11.1000067,15.4886076 11.7860832,15.4945694 12.2177,15.0777 L15.1261,12.1693 C15.7708612,11.5230891 15.7708612,10.4769109 15.1261,9.8307 L12.2177,6.9223 C11.7860832,6.50543057 11.1000067,6.51139239 10.6756996,6.93569957 C10.2513924,7.36000675 10.2454306,8.04608322 10.6623,8.4777 L12.0846,9.9 L7.04,9.9 C6.43248678,9.9 5.94,10.3924868 5.94,11 C5.94,11.6075132 6.43248678,12.1 7.04,12.1 L12.0846,12.1 L12.0846,12.1 Z" id="Shape"></path></svg> +<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path d="M14.072 11.063l-2.82 2.82a.46.46 0 0 0-.001.652l.495.495a.457.457 0 0 0 .653-.001l3.7-3.7a.46.46 0 0 0 .001-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.479-3.479a.464.464 0 0 0-.654.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/><path d="M10.08 11.063l-2.819 2.82a.46.46 0 0 0-.002.652l.496.495a.457.457 0 0 0 .652-.001l3.7-3.7a.46.46 0 0 0 .002-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.48-3.479a.464.464 0 0 0-.653.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/></svg> diff --git a/app/views/shared/icons/_mr_widget_empty_state.svg b/app/views/shared/icons/_mr_widget_empty_state.svg new file mode 100644 index 00000000000..6a811893b2d --- /dev/null +++ b/app/views/shared/icons/_mr_widget_empty_state.svg @@ -0,0 +1 @@ +<svg width="256" height="146" viewBox="0 0 256 146" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="178.714" height="115.389" rx="10"/><mask id="d" x="0" y="0" width="178.714" height="115.389" fill="#fff"><use xlink:href="#a"/></mask><path d="M8.796 31.515c.395.047.8.072 1.207.072h23.065c5.536 0 10.003-4.475 10.003-9.994v-11.6C43.07 4.476 38.594 0 33.07 0H10.003C4.467 0 0 4.475 0 9.994v11.6c0 1.248.23 2.444.65 3.547H0v7.414c0 4.094 2.394 5.113 5.342 2.28l3.454-3.32z" id="b"/><mask id="e" x="0" y="0" width="43.071" height="36.437" fill="#fff"><use xlink:href="#b"/></mask><path d="M8.796 31.515c.395.047.8.072 1.207.072h23.065c5.536 0 10.003-4.475 10.003-9.994v-11.6C43.07 4.476 38.594 0 33.07 0H10.003C4.467 0 0 4.475 0 9.994v11.6c0 1.248.23 2.444.65 3.547H0v7.414c0 4.094 2.394 5.113 5.342 2.28l3.454-3.32z" id="c"/><mask id="f" x="0" y="0" width="43.071" height="36.437" fill="#fff"><use xlink:href="#c"/></mask></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.868)" fill="#F9F9F9"><rect x="19.286" width="77.143" height="14.182" rx="7.091"/><rect y="28.364" width="84.857" height="14.182" rx="7.091"/><rect x="133.714" y="42.546" width="122.143" height="14.182" rx="7.091"/><rect x="82.929" y="126.992" width="101.571" height="14.182" rx="7.091"/><rect x="42.429" y="99.273" width="101.571" height="14.182" rx="7.091"/><rect x="19.929" y="70.909" width="225" height="14.182" rx="7.091"/><path d="M98.37 14.182H13.488h13.81a7.098 7.098 0 0 1 7.094 7.09 7.09 7.09 0 0 1-7.094 7.092h-13.81 84.88-23.452a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.096-7.092h23.452zm162 42.545h-75.238 23.452a7.098 7.098 0 0 1 7.095 7.09 7.09 7.09 0 0 1-7.096 7.092h-23.452 75.237-23.453a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.095-7.093h23.452zM103.512 85.09H28.275h23.452a7.098 7.098 0 0 1 7.095 7.092 7.09 7.09 0 0 1-7.095 7.09H28.275h75.237H80.06a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.095-7.09h23.452zm48.215 28.365H76.49 90.3a7.098 7.098 0 0 1 7.093 7.09 7.09 7.09 0 0 1-7.094 7.092H76.49h75.237-33.096a7.098 7.098 0 0 1-7.094-7.09 7.09 7.09 0 0 1 7.095-7.092h33.097z"/></g><g transform="translate(38.57 12.248)"><use stroke="#EEE" mask="url(#d)" stroke-width="8" fill="#FFF" xlink:href="#a"/><path fill="#EEE" d="M2.57 18.694h174.215v2.58H2.57z"/><g transform="translate(21.857 38.678)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(21.857 59.95)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#FC6D26" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(21.857 81.223)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(100.93 38.033)"><rect fill="#FDE5D8" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="14.826" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="21.917" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" y="21.273" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="37.286" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="25.071" y="35.455" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="18.643" y="28.364" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="30.857" y="21.273" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="35.455" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="21.857" y="21.273" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="30.857" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="39.857" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="49.5" y="14.182" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="29.008" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="36.099" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="43.19" width="3.857" height="1.289" rx=".645"/><rect fill="#6B4FBB" x="9.643" y="42.546" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="56.727" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="34.071" y="49.636" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="56.727" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="18.643" y="49.636" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="21.857" y="42.546" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" y="49.636" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="49.636" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="50.281" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="57.372" width="3.857" height="1.289" rx=".645"/></g></g><g transform="translate(196.07)"><use stroke="#FDE5D8" mask="url(#e)" stroke-width="8" fill="#FFF" xlink:href="#b"/><rect fill="#FDB692" x="9" y="9.025" width="18.643" height="1.934" rx=".967"/><rect fill="#FDB692" x="9" y="14.826" width="25.071" height="1.934" rx=".967"/><rect fill="#FDB692" x="9" y="20.628" width="18.643" height="1.934" rx=".967"/></g><g transform="translate(189 41.256)"><ellipse stroke="#FC6D26" stroke-width="3" fill="#FFF7F4" cx="10.286" cy="9.669" rx="9.643" ry="9.669"/><path d="M.023 9.002a8.352 8.352 0 0 0 7.94-4.308M9 .644c0-.21-.008-.416-.023-.62" stroke="#FC6D26" stroke-width="2"/><path d="M5.045 2.008A10.266 10.266 0 0 0 13.5 6.446c2.112 0 4.076-.638 5.71-1.733" stroke="#FC6D26" stroke-width="2"/><ellipse fill="#FC6D26" cx="6.75" cy="11.281" rx=".964" ry=".967"/><ellipse fill="#FC6D26" cx="13.821" cy="11.281" rx=".964" ry=".967"/></g><g transform="translate(46.93 96.05)"><ellipse stroke="#6B4FBB" stroke-width="3" fill="#F4F1FA" cx="9.643" cy="10.314" rx="9.643" ry="9.669"/><path d="M12.86 4.51h-.005L11.25 2.58 9.645 4.51H9.64L8.036 2.58 6.43 4.51h-.002L4.82 2.58 3.215 4.512h-1.75A9.646 9.646 0 0 1 9.642 0c3.447 0 6.47 1.8 8.176 4.508h-1.75l-1.605-1.93L12.86 4.51z" fill="#6B4FBB"/><ellipse fill="#6B4FBB" cx="6.107" cy="11.281" rx=".964" ry=".967"/><ellipse fill="#6B4FBB" cx="13.179" cy="11.281" rx=".964" ry=".967"/></g><g transform="matrix(-1 0 0 1 56.57 54.794)"><use stroke="#E2DCF2" mask="url(#f)" stroke-width="8" fill="#FFF" xlink:href="#c"/><rect fill="#6B4FBB" opacity=".5" x="15.429" y="9.025" width="18.643" height="1.934" rx=".967"/><rect fill="#6B4FBB" opacity=".5" x="21.857" y="14.826" width="12.214" height="1.934" rx=".967"/><rect fill="#6B4FBB" opacity=".5" x="21.857" y="20.628" width="12.214" height="1.934" rx=".967"/></g></g></svg> diff --git a/app/views/shared/icons/_scroll_down.svg b/app/views/shared/icons/_scroll_down.svg index acf22ac9314..1d22870ec09 100644 --- a/app/views/shared/icons/_scroll_down.svg +++ b/app/views/shared/icons/_scroll_down.svg @@ -1,3 +1,5 @@ -<svg width="16" height="33" class="gitlab-icon-scroll-down" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> - <path fill="#ffffff" d="M1.385 5.534v12.47a4.145 4.145 0 0 0 4.144 4.15h4.942a4.151 4.151 0 0 0 4.144-4.15V5.535a4.145 4.145 0 0 0-4.144-4.15H5.53a4.151 4.151 0 0 0-4.144 4.15zM8.88 30.27v-4.351a.688.688 0 0 0-.69-.688.687.687 0 0 0-.69.688v4.334l-1.345-1.346a.69.69 0 0 0-.976.976l2.526 2.526a.685.685 0 0 0 .494.2.685.685 0 0 0 .493-.2l2.526-2.526a.69.69 0 1 0-.976-.976L8.88 30.27zM0 5.534A5.536 5.536 0 0 1 5.529 0h4.942A5.53 5.53 0 0 1 16 5.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 18.005V5.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0V6.544z" fill-rule="evenodd"/> +<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg"> + <path class="first-triangle" d="M1.048 14.155a.508.508 0 0 0-.32.105c-.091.07-.136.154-.136.25v.71c0 .095.045.178.135.249.09.07.197.105.321.105h10.043c.124 0 .23-.035.321-.105.09-.07.136-.154.136-.25v-.71c0-.095-.045-.178-.136-.249a.508.508 0 0 0-.32-.105"/> + <path class="second-triangle" d="M.687 8.027c-.09-.087-.122-.16-.093-.22.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 12.91a.458.458 0 0 1-.136.089h-.37a.626.626 0 0 1-.136-.09"/> + <path class="third-triangle" d="M.687 1.027C.597.94.565.867.594.807c.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 5.91A.458.458 0 0 1 6.257 6h-.37a.626.626 0 0 1-.136-.09"/> </svg> diff --git a/app/views/shared/icons/_scroll_down_hover_active.svg b/app/views/shared/icons/_scroll_down_hover_active.svg deleted file mode 100644 index 262576acf54..00000000000 --- a/app/views/shared/icons/_scroll_down_hover_active.svg +++ /dev/null @@ -1,3 +0,0 @@ -<svg width="16" height="33" class="gitlab-icon-scroll-down-hover" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> - <path fill="#ffffff" d="M8.88 30.27v-4.351a.688.688 0 0 0-.69-.688.687.687 0 0 0-.69.688v4.334l-1.345-1.346a.69.69 0 0 0-.976.976l2.526 2.526a.685.685 0 0 0 .494.2.685.685 0 0 0 .493-.2l2.526-2.526a.69.69 0 1 0-.976-.976L8.88 30.27zM0 5.534A5.536 5.536 0 0 1 5.529 0h4.942A5.53 5.53 0 0 1 16 5.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 18.005V5.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0V6.544z" fill-rule="evenodd"/> -</svg> diff --git a/app/views/shared/icons/_scroll_up.svg b/app/views/shared/icons/_scroll_up.svg index f11288fd59c..70b1e4d9c91 100644 --- a/app/views/shared/icons/_scroll_up.svg +++ b/app/views/shared/icons/_scroll_up.svg @@ -1,3 +1 @@ -<svg width="16" height="33" class="gitlab-icon-scroll-up" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> - <path fill="#ffffff" d="M1.385 14.534v12.47a4.145 4.145 0 0 0 4.144 4.15h4.942a4.151 4.151 0 0 0 4.144-4.15v-12.47a4.145 4.145 0 0 0-4.144-4.15H5.53a4.151 4.151 0 0 0-4.144 4.15zM8.88 2.609V6.96a.688.688 0 0 1-.69.688.687.687 0 0 1-.69-.688V2.627L6.155 3.972a.69.69 0 0 1-.976-.976L7.705.47a.685.685 0 0 1 .494-.2.685.685 0 0 1 .493.2l2.526 2.526a.69.69 0 1 1-.976.976L8.88 2.609zM0 14.534A5.536 5.536 0 0 1 5.529 9h4.942A5.53 5.53 0 0 1 16 14.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 27.005V14.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0v-2.143z" fill-rule="evenodd"/> -</svg> +<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg"><path d="M1.048 1.845a.508.508 0 0 1-.32-.105c-.091-.07-.136-.154-.136-.25V.78c0-.095.045-.178.135-.249a.508.508 0 0 1 .321-.105h10.043c.124 0 .23.035.321.105.09.07.136.154.136.25v.71c0 .095-.045.178-.136.249a.508.508 0 0 1-.32.105"/><path d="M.687 7.973c-.09.087-.122.16-.093.22.028.06.104.09.228.09h10.5c.123 0 .2-.03.228-.09.029-.06-.002-.133-.093-.22L6.393 3.09A.458.458 0 0 0 6.257 3h-.37a.626.626 0 0 0-.136.09"/><path d="M.687 14.973c-.09.087-.122.16-.093.22.028.06.104.09.228.09h10.5c.123 0 .2-.03.228-.09.029-.06-.002-.133-.093-.22L6.393 10.09A.458.458 0 0 0 6.257 10h-.37a.626.626 0 0 0-.136.09"/></svg> diff --git a/app/views/shared/icons/_scroll_up_hover_active.svg b/app/views/shared/icons/_scroll_up_hover_active.svg deleted file mode 100644 index 4658dbb1bb7..00000000000 --- a/app/views/shared/icons/_scroll_up_hover_active.svg +++ /dev/null @@ -1,3 +0,0 @@ -<svg width="16" height="33" class="gitlab-icon-scroll-up-hover" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> - <path fill="#ffffff" d="M8.88 2.646l1.362 1.362a.69.69 0 0 0 .976-.976L8.692.507A.685.685 0 0 0 8.2.306a.685.685 0 0 0-.494.2L5.179 3.033a.69.69 0 1 0 .976.976L7.5 2.663v4.179c0 .38.306.688.69.688.381 0 .69-.306.69-.688V2.646zM0 14.534A5.536 5.536 0 0 1 5.529 9h4.942A5.53 5.53 0 0 1 16 14.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 27.005V14.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0v-2.143z" fill-rule="evenodd"/> -</svg> diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml new file mode 100644 index 00000000000..a8a6d84128d --- /dev/null +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -0,0 +1,53 @@ +- type = local_assigns.fetch(:type) + +%aside.issues-bulk-update.js-right-sidebar.right-sidebar.affix-top{ data: { "offset-top" => "50", "spy" => "affix" }, "aria-live" => "polite" } + .issuable-sidebar + = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do + .block + .filter-item.inline.update-issues-btn.pull-left + = button_tag "Update all", class: "btn update-selected-issues btn-info", disabled: true + = button_tag "Cancel", class: "btn btn-default js-bulk-update-menu-hide pull-right" + .block + .title + Status + .filter-item + = dropdown_tag("Select status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do + %ul + %li + %a{ href: "#", data: { id: "reopen" } } Open + %li + %a{ href: "#", data: { id: "close" } } Closed + .block + .title + Assignee + .filter-item + - if type == :issues + - field_name = "update[assignee_ids][]" + - else + - field_name = "update[assignee_id]" + = dropdown_tag("Select assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) + .block + .title + Milestone + .filter-item + = dropdown_tag("Select 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, default_label: "Milestone" } }) + .block + .title + Labels + .filter-item.labels-filter + = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: "Apply a label", 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, default_label: "Labels" }, label_name: "Select labels", no_default_styles: true + .block + .title + Subscriptions + .filter-item + = dropdown_tag("Select subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do + %ul + %li + %a{ href: "#", data: { id: "subscribe" } } Subscribe + %li + %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe + + = hidden_field_tag "update[issuable_ids]", [] + = hidden_field_tag :state_event, params[:state_event] + diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 1a12f110945..2cabbc8c560 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -6,10 +6,6 @@ = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do - if params[:search].present? = hidden_field_tag :search, params[:search] - - if @bulk_edit - .check-all-holder - = check_box_tag "check_all_issues", nil, false, - class: "check_all_issues left" .issues-other-filters .filter-item.inline - if params[:author_id].present? @@ -36,42 +32,12 @@ .pull-right = render 'shared/sort_dropdown' - - if @bulk_edit - .issues_bulk_update.hide - = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do - .filter-item.inline - = dropdown_tag("Status", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do - %ul - %li - %a{ href: "#", data: { id: "reopen" } } Open - %li - %a{ href: "#", data: {id: "close" } } Closed - .filter-item.inline - = dropdown_tag("Assignee", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) - .filter-item.inline - = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'issue-bulk-update-dropdown-toggle 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]", default_label: "Milestone", 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'], dropdown_title: 'Apply a label', 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: "issue-bulk-update-dropdown-toggle js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do - %ul - %li - %a{ href: "#", data: { id: "subscribe" } } Subscribe - %li - %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe - - = hidden_field_tag 'update[issuable_ids]', [] - = hidden_field_tag :state_event, params[:state_event] - .filter-item.inline - = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save" - has_labels = @labels && @labels.any? .row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) } - if has_labels = render 'shared/labels_row', labels: @labels :javascript - new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); new IssueStatusSelect(); diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 7748351b333..c016aa2abcd 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -17,7 +17,7 @@ = render 'shared/issuable/form/template_selector', issuable: issuable = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) -= render 'shared/issuable/form/description', issuable: issuable, form: form, project: project += render 'shared/form_elements/description', model: issuable, form: form, project: project - if issuable.respond_to?(:confidential) .form-group diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index 93c7fa0c7d6..34911fd2712 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -9,8 +9,10 @@ - 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 = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path, default_label: "Labels"} - dropdown_data.merge!(data_options) +- label_name = local_assigns.fetch(:label_name, "Labels") +- no_default_styles = local_assigns.fetch(:no_default_styles, false) - classes << 'js-extra-options' if extra_options - classes << 'js-filter-submit' if filter_submit @@ -20,8 +22,9 @@ .dropdown %button.dropdown-menu-toggle.js-label-select.js-multiselect{ class: classes.join(' '), type: "button", data: dropdown_data } - %span.dropdown-toggle-text{ class: ("is-default" if selected.nil? || selected.empty?) } - = multi_label_name(selected, "Labels") + - apply_is_default_styles = (selected.nil? || selected.empty?) && !no_default_styles + %span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) } + = multi_label_name(selected, label_name) = 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: dropdown_title, show_footer: show_footer, show_create: show_create } diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml index f0d50828e2a..6750921338a 100644 --- a/app/views/shared/issuable/_milestone_dropdown.html.haml +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -6,7 +6,7 @@ - if selected.present? || params[:milestone_title].present? = hidden_field_tag(name, name == :milestone_title ? selected_text : 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, show_started: show_started, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do + 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, show_started: show_started, field_name: name, selected: selected_text, 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 diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml index ad995cbe962..cf7ba52d840 100644 --- a/app/views/shared/issuable/_nav.html.haml +++ b/app/views/shared/issuable/_nav.html.haml @@ -1,25 +1,24 @@ - type = local_assigns.fetch(:type, :issues) - page_context_word = type.to_s.humanize(capitalize: false) - issuables = @issues || @merge_requests +- closed_title = 'Filter by issues that are currently closed.' %ul.nav-links.issues-state-filters %li{ class: active_when(params[:state] == 'opened') }> - = link_to page_filter_path(state: 'opened', label: true), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened." do + %button.btn.btn-link{ id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", type: 'button', data: { state: 'opened' } } #{issuables_state_counter_text(type, :opened)} - if type == :merge_requests %li{ class: active_when(params[:state] == 'merged') }> - = link_to page_filter_path(state: 'merged', label: true), id: 'state-merged', title: 'Filter by merge requests that are currently merged.' do + %button.btn.btn-link{ id: 'state-merged', title: 'Filter by merge requests that are currently merged.', type: 'button', data: { state: 'merged' } } #{issuables_state_counter_text(type, :merged)} - %li{ class: active_when(params[:state] == 'closed') }> - = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.' do - #{issuables_state_counter_text(type, :closed)} - - else - %li{ class: active_when(params[:state] == 'closed') }> - = link_to page_filter_path(state: 'closed', label: true), id: 'state-all', title: 'Filter by issues that are currently closed.' do - #{issuables_state_counter_text(type, :closed)} + - closed_title = 'Filter by merge requests that are currently closed and unmerged.' + + %li{ class: active_when(params[:state] == 'closed') }> + %button.btn.btn-link{ id: 'state-closed', title: closed_title, type: 'button', data: { state: 'closed' } } + #{issuables_state_counter_text(type, :closed)} %li{ class: active_when(params[:state] == 'all') }> - = link_to page_filter_path(state: 'all', label: true), id: 'state-all', title: "Show all #{page_context_word}." do + %button.btn.btn-link{ id: 'state-all', title: "Show all #{page_context_word}.", type: 'button', data: { state: 'all' } } #{issuables_state_counter_text(type, :all)} diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index f7b87171573..d3d290692a2 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -6,28 +6,25 @@ = 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" + - if @can_bulk_update + .check-all-holder.hidden + = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left" .issues-other-filters.filtered-search-wrapper .filtered-search-box - if type != :boards_modal && type != :boards - = dropdown_tag(content_tag(:i, '', class: 'fa fa-history'), + = dropdown_tag(custom_icon('icon_history'), options: { wrapper_class: "filtered-search-history-dropdown-wrapper", toggle_class: "filtered-search-history-dropdown-toggle-button", dropdown_class: "filtered-search-history-dropdown", content_class: "filtered-search-history-dropdown-content", title: "Recent searches" }) do - .js-filtered-search-history-dropdown + .js-filtered-search-history-dropdown{ data: { project_full_path: @project.full_path } } .filtered-search-box-input-container .scroll-container %ul.tokens-container.list-unstyled %li.input-token %input.form-control.filtered-search{ id: "filtered-search-#{type.to_s}", placeholder: 'Search or filter results...', data: { 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } = icon('filter') - %button.clear-search.hidden{ type: 'button' } - = icon('times') #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { action: 'submit' } } @@ -45,32 +42,29 @@ {{hint}} %span.js-filter-tag.dropdown-light-content {{tag}} - #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } } + #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu + - if current_user + %ul{ data: { dropdown: true } } + = render 'shared/issuable/user_dropdown_item', + user: current_user %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.dropdown-user - %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } - .dropdown-user-details - %span - {{name}} - %span.dropdown-light-content - @{{username}} - #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } + = render 'shared/issuable/user_dropdown_item', + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } + #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link No Assignee %li.divider + - if current_user + = render 'shared/issuable/user_dropdown_item', + user: current_user %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.dropdown-user - %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } - .dropdown-user-details - %span - {{name}} - %span.dropdown-light-content - @{{username}} - #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } + = render 'shared/issuable/user_dropdown_item', + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } + #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link @@ -86,7 +80,7 @@ %li.filter-dropdown-item %button.btn.btn-link.js-data-value {{title}} - #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } + #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link @@ -98,6 +92,8 @@ %span.dropdown-label-box{ style: 'background: {{color}}' } %span.label-title.js-data-value {{title}} + %button.clear-search.hidden{ type: 'button' } + = icon('times') .filter-dropdown-container - if type == :boards - if can?(current_user, :admin_list, @project) @@ -113,55 +109,11 @@ - elsif type != :boards_modal = render 'shared/sort_dropdown' - - if @bulk_edit - .issues_bulk_update.hide - = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do - .filter-item.inline - = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do - %ul - %li - %a{ href: "#", data: { id: "reopen" } } Open - %li - %a{ href: "#", data: { id: "close" } } Closed - .filter-item.inline - - if type == :issues - - field_name = "update[assignee_ids][]" - - else - - field_name = "update[assignee_id]" - - = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) - .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, default_label: "Milestone" } }) - .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', 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, default_label: "Labels" } - .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]", default_label: "Subscription" } } ) do - %ul - %li - %a{ href: "#", data: { id: "subscribe" } } Subscribe - %li - %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe - - = hidden_field_tag 'update[issuable_ids]', [] - = hidden_field_tag :state_event, params[:state_event] - .filter-item.inline.update-issues-btn - = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save" - - unless type === :boards_modal :javascript - new UsersSelect(); - new LabelsSelect(); - new MilestoneSelect(); - new IssueStatusSelect(); - new SubscriptionSelect(); - $(document).off('page:restore').on('page:restore', function (event) { if (gl.FilteredSearchManager) { - new gl.FilteredSearchManager(); + const filteredSearchManager = new gl.FilteredSearchManager(); + filteredSearchManager.setup(); } - Issuable.init(); - new gl.IssuableBulkActions({ - prefixId: 'issue_', - }); }); diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 418e1b2d73f..e49bd5ebb13 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -3,7 +3,7 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('sidebar') -%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } +%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } } - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header @@ -43,7 +43,7 @@ .selectbox.hide-collapsed = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil - = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }}) + = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true }}) - if issuable.has_attribute?(:time_estimate) #issuable-time-tracker.block // Fallback while content is loading diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 26567c08eb6..8adadd70d4d 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -32,7 +32,11 @@ .selectbox.hide-collapsed - issuable.assignees.each do |assignee| +<<<<<<< HEAD = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil +======= + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } diff --git a/app/views/shared/issuable/_user_dropdown_item.html.haml b/app/views/shared/issuable/_user_dropdown_item.html.haml new file mode 100644 index 00000000000..a82c01c6dc2 --- /dev/null +++ b/app/views/shared/issuable/_user_dropdown_item.html.haml @@ -0,0 +1,11 @@ +- user = local_assigns.fetch(:user) +- avatar = local_assigns.fetch(:avatar, { }) + +%li.filter-dropdown-item{ class: ('js-current-user' if user == current_user) } + %button.btn.btn-link.dropdown-user{ type: :button } + = user_avatar_without_link(user: user, lazy: avatar[:lazy], url: avatar[:url], size: 30) + .dropdown-user-details + %span + = user.name + %span.dropdown-light-content + = user.to_reference diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index f57b4d899ce..203d2adc8db 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -10,14 +10,14 @@ = form.label :source_branch, class: 'control-label' .col-sm-10 .issuable-form-select-holder - = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2', disabled: true }) + = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 ref-name', disabled: true }) .form-group = form.label :target_branch, class: 'control-label' .col-sm-10.target-branch-select-dropdown-container .issuable-form-select-holder = form.select(:target_branch, issuable.target_branches, { include_blank: true }, - { class: 'target_branch js-target-branch-select', + { class: 'target_branch js-target-branch-select ref-name', disabled: issuable.new_record?, data: { placeholder: "Select branch" }}) - if issuable.new_record? diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index d23f79be2be..7ffbcea5146 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -5,3 +5,16 @@ -# This check is duplicated below, to avoid conflicts with EE. - return unless issuable.can_remove_source_branch?(current_user) +<<<<<<< HEAD +======= + +.form-group + .col-sm-10.col-sm-offset-2 + - if issuable.can_remove_source_branch?(current_user) + .checkbox + - initial_checkbox_value = issuable.merge_params.key?('force_remove_source_branch') ? issuable.force_remove_source_branch? : true + = label_tag 'merge_request[force_remove_source_branch]' do + = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil + = check_box_tag 'merge_request[force_remove_source_branch]', '1', initial_checkbox_value + Remove source branch when merge request is accepted. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 diff --git a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml index 8119f19291b..1eea23be14c 100644 --- a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml @@ -2,7 +2,11 @@ .col-sm-10{ class: ("col-lg-8" if has_due_date) } .issuable-form-select-holder.selectbox - issuable.assignees.each do |assignee| +<<<<<<< HEAD = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } +======= + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name, avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - if issuable.assignees.length === 0 = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml index fb795ad1c72..d97fdf179d7 100644 --- a/app/views/shared/members/_access_request_buttons.html.haml +++ b/app/views/shared/members/_access_request_buttons.html.haml @@ -2,16 +2,17 @@ .project-action-button.inline - if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id)) - = link_to "Leave #{model_name}", polymorphic_path([:leave, source, :members]), + - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project') + = link_to link_text, polymorphic_path([:leave, source, :members]), method: :delete, data: { confirm: leave_confirmation_message(source) }, class: 'btn' - elsif requester = source.requesters.find_by(user_id: current_user.id) - = link_to 'Withdraw Access Request', polymorphic_path([:leave, source, :members]), + = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), method: :delete, data: { confirm: remove_member_message(requester) }, class: 'btn' - elsif source.request_access_enabled && can?(current_user, :request_access, source) - = link_to 'Request Access', polymorphic_path([:request_access, source, :members]), + = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]), method: :post, class: 'btn' diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 5e8a2a0f5d8..9bb87640319 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -1,4 +1,4 @@ -- affix_offset = local_assigns.fetch(:affix_offset, "102") +- affix_offset = local_assigns.fetch(:affix_offset, "50") - project = local_assigns[:project] %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 81d97eabe65..7ce6130de60 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -9,6 +9,27 @@ - else is supported - %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' } - = icon('file-image-o', class: 'toolbar-button-icon') - Attach a file + + %span.uploading-container + %span.uploading-progress-container.hide + = icon('file-image-o', class: 'toolbar-button-icon') + %span.attaching-file-message + -# Populated by app/assets/javascripts/dropzone_input.js + %span.uploading-progress 0% + %span.uploading-spinner + = icon('spinner spin', class: 'toolbar-button-icon') + + %span.uploading-error-container.hide + %span.uploading-error-icon + = icon('file-image-o', class: 'toolbar-button-icon') + %span.uploading-error-message + -# Populated by app/assets/javascripts/dropzone_input.js + %button.retry-uploading-link{ type: 'button' } Try again + or + %button.attach-new-file.markdown-selector{ type: 'button' } attach a new file + + %button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' } + = icon('file-image-o', class: 'toolbar-button-icon') + Attach a file + + %button.btn.btn-default.btn-xs.hide.button-cancel-uploading-files{ type: 'button' } Cancel diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index a7bf610b9c7..1e34b7c1e76 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -18,7 +18,7 @@ .note-header .note-header-info %a{ href: user_path(note.author) } - %span.hidden-xs + %span.note-header-author-name = sanitize(note.author.name) %span.note-headline-light = note.author.to_reference diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 9930cbd96d7..5902798dfd0 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -3,24 +3,23 @@ = render 'shared/notes/edit_form', project: @project -%ul.notes.notes-form.timeline - %li.timeline-entry - .flash-container.timeline-content +- if can_create_note? + %ul.notes.notes-form.timeline + %li.timeline-entry + .flash-container.timeline-content - - if can_create_note? .timeline-icon.hidden-xs.hidden-sm %a.author_link{ href: user_path(current_user) } = image_tag avatar_icon(current_user), alt: current_user.to_reference, class: 'avatar s40' .timeline-content.timeline-content-form = render "shared/notes/form", view: diff_view - - elsif !current_user - .disabled-comment.text-center - .disabled-comment-text.inline - Please - = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') - or - = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') - to post a comment +- elsif !current_user + .disabled-comment.text-center.prepend-top-default + Please + = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') + or + = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') + to comment :javascript - var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}") + var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}", #{autocomplete}) diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index 1d072c16b32..e99d8d0973f 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -6,14 +6,14 @@ .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.notifications-btn#notifications-button{ type: "button", data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } } = 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) } } = 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) } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) = icon("caret-down") diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml index 708adbc38f1..752932e6045 100644 --- a/app/views/shared/notifications/_custom_notifications.html.haml +++ b/app/views/shared/notifications/_custom_notifications.html.haml @@ -1,11 +1,11 @@ -.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), aria: { labelledby: "custom-notifications-title" } } +.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), "aria-labelledby": "custom-notifications-title" } .modal-dialog .modal-content .modal-header - %button.close{ type: "button", data: { dismiss: "modal" }, aria: { label: "close" } } - %span{ aria: { hidden: "true" } } × + %button.close{ type: "button", "aria-label": "close", data: { dismiss: "modal" } } + %span{ "aria-hidden": "true" } } × %h4#custom-notifications-title.modal-title - Custom notification events + #{ _('Custom notification events') } .modal-body .container-fluid @@ -13,12 +13,11 @@ = hidden_setting_source_input(notification_setting) .row .col-lg-4 - %h4.prepend-top-0 - Notification events + %h4.prepend-top-0= _('Notification events') %p - Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out - = succeed "." do - %a{ href: help_page_path('workflow/notifications'), target: "_blank" } notification emails + - notification_link = link_to _('notification emails'), help_page_path('workflow/notifications'), target: '_blank' + - paragraph = _('Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.') % { notification_link: notification_link.html_safe } + #{ paragraph.html_safe } .col-lg-8 - NotificationSetting::EMAIL_EVENTS.each_with_index do |event, index| - field_id = "#{notifications_menu_identifier("modal", notification_setting)}_notification_setting[#{event}]" diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index cf0540afb38..fbc335f6176 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -7,7 +7,7 @@ - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - cache_key = project_list_cache_key(project) -- updated_tooltip = time_ago_with_tooltip(project.updated_at) +- updated_tooltip = time_ago_with_tooltip(project.last_activity_at) %li.project-row{ class: css_class } = cache(cache_key) do diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 0296597b294..8549cb91b03 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -3,7 +3,7 @@ = page_specific_javascript_bundle_tag('snippet') .snippet-form-holder - = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit" } do |f| + = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit common-note-form" } do |f| = form_errors(@snippet) .form-group @@ -11,6 +11,8 @@ .col-sm-10 = f.text_field :title, class: 'form-control', required: true, autofocus: true + = render 'shared/form_elements/description', model: @snippet, project: @project, form: f + = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet .file-editor @@ -23,6 +25,9 @@ .file-content.code %pre#editor= @snippet.content = f.hidden_field :content, class: 'snippet-file-content' + - if params[:files] + - params[:files].each_with_index do |file, index| + = hidden_field_tag "files[]", file, id: "files_#{index}" .form-actions - if @snippet.new_record? diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 501c09d71d5..813d8d69d8d 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -22,3 +22,9 @@ - if @snippet.updated_at != @snippet.created_at = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) + - if @snippet.description.present? + .description + .wiki + = markdown_field(@snippet, :description) + %textarea.hidden.js-task-list-field + = @snippet.description diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 37c3e61912c..1f0e7629fb4 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -54,10 +54,10 @@ %p.light This URL will be triggered when a merge request is created/updated/merged %li - = form.check_box :build_events, class: 'pull-left' + = form.check_box :job_events, class: 'pull-left' .prepend-left-20 - = form.label :build_events, class: 'list-label' do - %strong Jobs events + = form.label :job_events, class: 'list-label' do + %strong Job events %p.light This URL will be triggered when the job status changes %li diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index 679a5e934da..098a88c48c5 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -1,13 +1,10 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do + = link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do - = icon('pencil', class: 'link-highlight') - = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do - = icon('trash-o', class: 'danger-highlight') + + = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 51dbbc32cc9..216184eb839 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -9,4 +9,4 @@ .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes= render "shared/notes/notes_with_form" + #notes= render "shared/notes/notes_with_form", :autocomplete => false diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index dbb9216f7d0..f246bd7a586 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -56,11 +56,11 @@ = icon('skype') - unless @user.linkedin.blank? .profile-link-holder.middle-dot-divider - = link_to "https://www.linkedin.com/in/#{@user.linkedin}", title: "LinkedIn" do + = link_to linkedin_url(@user), 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 + = link_to twitter_url(@user), title: "Twitter" do = icon('twitter-square') - unless @user.website_url.blank? .profile-link-holder.middle-dot-divider @@ -71,7 +71,7 @@ = @user.location - unless @user.organization.blank? .profile-link-holder.middle-dot-divider - = icon('building') + = icon('briefcase') = @user.organization - if @user.bio.present? @@ -100,7 +100,7 @@ Snippets %div{ class: container_class } - - if @user == current_user && !show_user_callout? + - if @user == current_user && show_callout?('user_callout_dismissed') = render 'shared/user_callout' .tab-content #activity.tab-pane diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index e17add7421f..bf009dfab0f 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -11,15 +11,6 @@ class BuildSuccessWorker private def create_deployment(build) - 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) + CreateDeploymentService.new(build).execute end end diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb index 2f02235b0ac..0a55aab63fd 100644 --- a/app/workers/gitlab_usage_ping_worker.rb +++ b/app/workers/gitlab_usage_ping_worker.rb @@ -3,29 +3,17 @@ class GitlabUsagePingWorker include Sidekiq::Worker include CronjobQueue - include HTTParty def perform - return unless current_application_settings.usage_ping_enabled - # Multiple Sidekiq workers could run this. We should only do this at most once a day. return unless try_obtain_lease - begin - HTTParty.post(url, - body: Gitlab::UsageData.to_json(force_refresh: true), - headers: { 'Content-type' => 'application/json' } - ) - rescue HTTParty::Error => e - Rails.logger.info "Unable to contact GitLab, Inc.: #{e}" - end + SubmitUsagePingService.new.execute end + private + def try_obtain_lease Gitlab::ExclusiveLease.new('gitlab_usage_ping_worker:ping', timeout: LEASE_TIMEOUT).try_obtain end - - def url - 'https://version.gitlab.com/usage_data' - end end diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb index c9658b3fe17..22f67fa9e9f 100644 --- a/app/workers/irker_worker.rb +++ b/app/workers/irker_worker.rb @@ -142,10 +142,10 @@ class IrkerWorker end def files_count(commit) - diffs = commit.raw_diffs(deltas_only: true) + diff_size = commit.raw_deltas.size - files = "#{diffs.real_size} file" - files += 's' if diffs.size > 1 + files = "#{diff_size} file" + files += 's' if diff_size > 1 files end diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb new file mode 100644 index 00000000000..bfae0c77700 --- /dev/null +++ b/app/workers/namespaceless_project_destroy_worker.rb @@ -0,0 +1,43 @@ +# Worker to destroy projects that do not have a namespace +# +# It destroys everything it can without having the info about the namespace it +# used to belong to. Projects in this state should be rare. +# The worker will reject doing anything for projects that *do* have a +# namespace. For those use ProjectDestroyWorker instead. +class NamespacelessProjectDestroyWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def self.bulk_perform_async(args_list) + Sidekiq::Client.push_bulk('class' => self, 'queue' => sidekiq_options['queue'], 'args' => args_list) + end + + def perform(project_id) + begin + project = Project.unscoped.find(project_id) + rescue ActiveRecord::RecordNotFound + return + end + return unless project.namespace_id.nil? # Reject doing anything for projects that *do* have a namespace + + project.team.truncate + + unlink_fork(project) if project.forked? + + # Override Project#remove_pages for this instance so it doesn't do anything + def project.remove_pages + end + + project.destroy! + end + + private + + def unlink_fork(project) + merge_requests = project.forked_from_project.merge_requests.opened.from_project(project) + + merge_requests.update_all(state: 'closed') + + project.forked_project_link.destroy + end +end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index a449a765f7b..7b485b3363c 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -3,12 +3,18 @@ class PipelineScheduleWorker include CronjobQueue def perform - Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now).find_each do |schedule| + Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) + .preload(:owner, :project).find_each do |schedule| begin + unless schedule.runnable_by_owner? + schedule.deactivate! + next + end + Ci::CreatePipelineService.new(schedule.project, schedule.owner, ref: schedule.ref) - .execute(save_on_errors: false, schedule: schedule) + .execute(:schedule, save_on_errors: false, schedule: schedule) rescue => e Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}" ensure diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 127d8dfbb61..c29571d3c62 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -20,13 +20,32 @@ class PostReceive # Nothing defined here yet. else process_project_changes(post_received) + process_repository_update(post_received) end end - def process_project_changes(post_received) - post_received.changes.each do |change| - oldrev, newrev, ref = change.strip.split(' ') + def process_repository_update(post_received) + changes = [] + refs = Set.new + + post_received.changes_refs do |oldrev, newrev, ref| + @user ||= post_received.identify(newrev) + unless @user + log("Triggered hook for non-existing user \"#{post_received.identifier}\"") + return false + end + + changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref) + refs << ref + end + + hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, @user, changes, refs.to_a) + SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks) + end + + def process_project_changes(post_received) + post_received.changes_refs do |oldrev, newrev, ref| @user ||= post_received.identify(newrev) unless @user diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 2f7967cf531..fe6a49976e0 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -17,12 +17,14 @@ class ProcessCommitWorker project = Project.find_by(id: project_id) return unless project + return if commit_exists_in_upstream?(project, commit_hash) user = User.find_by(id: user_id) return unless user commit = build_commit(project, commit_hash) + author = commit.author || user process_commit_message(project, commit, user, author, default) @@ -73,4 +75,16 @@ class ProcessCommitWorker Commit.from_hash(hash, project) end + + private + + # Avoid reprocessing commits that already exist in the upstream + # when project is forked. This will also prevent duplicated system notes. + def commit_exists_in_upstream?(project, commit_hash) + return false unless project.forked? + + upstream_project = project.forked_from_project + commit_id = commit_hash.with_indifferent_access[:id] + upstream_project.commit(commit_id).present? + end end diff --git a/app/workers/remove_old_web_hook_logs_worker.rb b/app/workers/remove_old_web_hook_logs_worker.rb new file mode 100644 index 00000000000..555e1bb8691 --- /dev/null +++ b/app/workers/remove_old_web_hook_logs_worker.rb @@ -0,0 +1,10 @@ +class RemoveOldWebHookLogsWorker + include Sidekiq::Worker + include CronjobQueue + + WEB_HOOK_LOG_LIFETIME = 2.days + + def perform + WebHookLog.destroy_all(['created_at < ?', Time.now - WEB_HOOK_LOG_LIFETIME]) + end +end diff --git a/app/workers/repository_check/clear_worker.rb b/app/workers/repository_check/clear_worker.rb index 1f1b38540ee..85bc9103538 100644 --- a/app/workers/repository_check/clear_worker.rb +++ b/app/workers/repository_check/clear_worker.rb @@ -8,7 +8,7 @@ module RepositoryCheck Project.select(:id).find_in_batches(batch_size: 100) do |batch| Project.where(id: batch.map(&:id)).update_all( last_repository_check_failed: nil, - last_repository_check_at: nil, + last_repository_check_at: nil ) end end diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb index 3d8bfc6fc6c..164586cf0b7 100644 --- a/app/workers/repository_check/single_repository_worker.rb +++ b/app/workers/repository_check/single_repository_worker.rb @@ -7,7 +7,7 @@ module RepositoryCheck project = Project.find(project_id) project.update_columns( last_repository_check_failed: !check(project), - last_repository_check_at: Time.now, + last_repository_check_at: Time.now ) end diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index efc99ec962a..a338523dc6b 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -1,4 +1,6 @@ class RepositoryForkWorker + ForkError = Class.new(StandardError) + include Sidekiq::Worker include Gitlab::ShellAdapter include DedicatedSidekiqQueue @@ -8,29 +10,31 @@ class RepositoryForkWorker source_path: source_path, target_path: target_path) - project = Project.find_by_id(project_id) - - unless project.present? - logger.error("Project #{project_id} no longer exists!") - return - end + project = Project.find(project_id) + project.import_start result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_path, project.repository_storage_path, target_path) - unless result - logger.error("Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}") - project.mark_import_as_failed('The project could not be forked.') - return - end + raise ForkError, "Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}" unless result project.repository.after_import - - unless project.valid_repo? - logger.error("Project #{project_id} had an invalid repository after fork") - project.mark_import_as_failed('The forked repository is invalid.') - return - end + raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo? project.import_finish + rescue ForkError => ex + fail_fork(project, ex.message) + raise + rescue => ex + return unless project + + fail_fork(project, ex.message) + raise ForkError, "#{ex.class} #{ex.message}" + end + + private + + def fail_fork(project, message) + Rails.logger.error(message) + project.mark_import_as_failed(message) end end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index b33ba2ed7c1..625476b7e01 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -1,4 +1,6 @@ class RepositoryImportWorker + ImportError = Class.new(StandardError) + include Sidekiq::Worker include DedicatedSidekiqQueue @@ -10,6 +12,8 @@ class RepositoryImportWorker @project = Project.find(project_id) @current_user = @project.creator + project.import_start + Gitlab::Metrics.add_event(:import_repository, import_url: @project.import_url, path: @project.path_with_namespace) @@ -17,13 +21,23 @@ class RepositoryImportWorker project.update_columns(import_jid: self.jid, import_error: nil) result = Projects::ImportService.new(project, current_user).execute - - if result[:status] == :error - project.mark_import_as_failed(result[:message]) - return - end + raise ImportError, result[:message] if result[:status] == :error project.repository.after_import project.import_finish + rescue ImportError => ex + fail_import(project, ex.message) + raise + rescue => ex + return unless project + + fail_import(project, ex.message) + raise ImportError, "#{ex.class} #{ex.message}" + end + + private + + def fail_import(project, message) + project.mark_import_as_failed(message) end end diff --git a/app/workers/system_hook_worker.rb b/app/workers/system_hook_worker.rb deleted file mode 100644 index 55d4e7d6dab..00000000000 --- a/app/workers/system_hook_worker.rb +++ /dev/null @@ -1,10 +0,0 @@ -class SystemHookWorker - include Sidekiq::Worker - include DedicatedSidekiqQueue - - sidekiq_options retry: 4 - - def perform(hook_id, data, hook_name) - SystemHook.find(hook_id).execute(data, hook_name) - end -end diff --git a/app/workers/project_web_hook_worker.rb b/app/workers/web_hook_worker.rb index d973e662ff2..ad5ddf02a12 100644 --- a/app/workers/project_web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -1,11 +1,13 @@ -class ProjectWebHookWorker +class WebHookWorker include Sidekiq::Worker include DedicatedSidekiqQueue sidekiq_options retry: 4 def perform(hook_id, data, hook_name) + hook = WebHook.find(hook_id) data = data.with_indifferent_access - WebHook.find(hook_id).execute(data, hook_name) + + WebHookService.new(hook, data, hook_name).execute end end diff --git a/changelogs/unreleased/10378-promote-blameless-culture.yml b/changelogs/unreleased/10378-promote-blameless-culture.yml new file mode 100644 index 00000000000..8cf64dfd793 --- /dev/null +++ b/changelogs/unreleased/10378-promote-blameless-culture.yml @@ -0,0 +1,4 @@ +--- +title: Changed Blame to Annotate in the UI to promote blameless culture +merge_request: 10378 +author: Ilya Vassilevsky diff --git a/changelogs/unreleased/12614-fix-long-message-from-mr.yml b/changelogs/unreleased/12614-fix-long-message-from-mr.yml new file mode 100644 index 00000000000..30408ea4216 --- /dev/null +++ b/changelogs/unreleased/12614-fix-long-message-from-mr.yml @@ -0,0 +1,4 @@ +--- +title: Implement web hook logging +merge_request: 11027 +author: Alexander Randa diff --git a/changelogs/unreleased/12910-snippets-description.yml b/changelogs/unreleased/12910-snippets-description.yml new file mode 100644 index 00000000000..ac3d754fee1 --- /dev/null +++ b/changelogs/unreleased/12910-snippets-description.yml @@ -0,0 +1,4 @@ +--- +title: Support descriptions for snippets +merge_request: +author: diff --git a/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml b/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml new file mode 100644 index 00000000000..9c17c3b949c --- /dev/null +++ b/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml @@ -0,0 +1,4 @@ +--- +title: Introduce an Events API +merge_request: 11755 +author: diff --git a/changelogs/unreleased/17489-hide-code-from-guests.yml b/changelogs/unreleased/17489-hide-code-from-guests.yml new file mode 100644 index 00000000000..eb6daffedfe --- /dev/null +++ b/changelogs/unreleased/17489-hide-code-from-guests.yml @@ -0,0 +1,4 @@ +--- +title: Hide clone panel and file list when user is only a guest +merge_request: +author: James Clark diff --git a/changelogs/unreleased/18927-reorder-issue-action-buttons.yml b/changelogs/unreleased/18927-reorder-issue-action-buttons.yml new file mode 100644 index 00000000000..793d6582940 --- /dev/null +++ b/changelogs/unreleased/18927-reorder-issue-action-buttons.yml @@ -0,0 +1,4 @@ +--- +title: Reorder Issue action buttons in order of usability +merge_request: 11642 +author: diff --git a/changelogs/unreleased/19107-404-when-creating-new-milestone-or-issue-for-project-that-has-issues-disabled.yml b/changelogs/unreleased/19107-404-when-creating-new-milestone-or-issue-for-project-that-has-issues-disabled.yml new file mode 100644 index 00000000000..bec9aa34761 --- /dev/null +++ b/changelogs/unreleased/19107-404-when-creating-new-milestone-or-issue-for-project-that-has-issues-disabled.yml @@ -0,0 +1,4 @@ +--- +title: 'New issue'/'New merge request' dropdowns should show only projects with issues/merge requests feature enabled +merge_request: 19107 +author: blackst0ne diff --git a/changelogs/unreleased/20517-delete-projects-issuescontroller-redirect_old.yml b/changelogs/unreleased/20517-delete-projects-issuescontroller-redirect_old.yml new file mode 100644 index 00000000000..1f3ab3a2c10 --- /dev/null +++ b/changelogs/unreleased/20517-delete-projects-issuescontroller-redirect_old.yml @@ -0,0 +1,4 @@ +--- +title: Remove redirect for old issue url containing id instead of iid +merge_request: 11135 +author: blackst0ne diff --git a/changelogs/unreleased/23036-replace-all-spinach-tests-with-rspec-feature-tests.yml b/changelogs/unreleased/23036-replace-all-spinach-tests-with-rspec-feature-tests.yml new file mode 100644 index 00000000000..b350b27d863 --- /dev/null +++ b/changelogs/unreleased/23036-replace-all-spinach-tests-with-rspec-feature-tests.yml @@ -0,0 +1,4 @@ +--- +title: Replace 'starred_projects.feature' spinach test with an rspec analog +merge_request: 11752 +author: blackst0ne diff --git a/changelogs/unreleased/23603-add-extra-functionality-for-the-top-right-button.yml b/changelogs/unreleased/23603-add-extra-functionality-for-the-top-right-button.yml new file mode 100644 index 00000000000..77f8e31e16e --- /dev/null +++ b/changelogs/unreleased/23603-add-extra-functionality-for-the-top-right-button.yml @@ -0,0 +1,4 @@ +--- +title: Add extra context-sensitive functionality for the top right menu button +merge_request: 11632 +author: diff --git a/changelogs/unreleased/24032-when-changing-project-visibility-setting-change-other-dropdowns-automatically.yml b/changelogs/unreleased/24032-when-changing-project-visibility-setting-change-other-dropdowns-automatically.yml new file mode 100644 index 00000000000..dbd8a538d51 --- /dev/null +++ b/changelogs/unreleased/24032-when-changing-project-visibility-setting-change-other-dropdowns-automatically.yml @@ -0,0 +1,4 @@ +--- +title: Automatically adjust project settings to match changes in project visibility +merge_request: 11831 +author: diff --git a/changelogs/unreleased/24196-protected-variables.yml b/changelogs/unreleased/24196-protected-variables.yml new file mode 100644 index 00000000000..71567a9d794 --- /dev/null +++ b/changelogs/unreleased/24196-protected-variables.yml @@ -0,0 +1,5 @@ +--- +title: Add protected variables which would only be passed to protected branches or + protected tags +merge_request: 11688 +author: diff --git a/changelogs/unreleased/24373-warning-message-go-away.yml b/changelogs/unreleased/24373-warning-message-go-away.yml new file mode 100644 index 00000000000..c0f2fd260ba --- /dev/null +++ b/changelogs/unreleased/24373-warning-message-go-away.yml @@ -0,0 +1,4 @@ +--- +title: 'Notes: Warning message should go away once resolved' +merge_request: 10823 +author: Jacopo Beschi @jacopo-beschi diff --git a/changelogs/unreleased/25373-jira-links.yml b/changelogs/unreleased/25373-jira-links.yml new file mode 100644 index 00000000000..09589d4b992 --- /dev/null +++ b/changelogs/unreleased/25373-jira-links.yml @@ -0,0 +1,4 @@ +--- +title: Don’t create comment on JIRA if it already exists for the entity +merge_request: +author: diff --git a/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml b/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml new file mode 100644 index 00000000000..af9fe3b5041 --- /dev/null +++ b/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml @@ -0,0 +1,4 @@ +--- +title: Add $CI_ENVIRONMENT_URL to predefined variables for pipelines +merge_request: 11695 +author: diff --git a/changelogs/unreleased/26325-system-hooks.yml b/changelogs/unreleased/26325-system-hooks.yml new file mode 100644 index 00000000000..62b8adaeccd --- /dev/null +++ b/changelogs/unreleased/26325-system-hooks.yml @@ -0,0 +1,4 @@ +--- +title: 'Backported new SystemHook event: `repository_update`' +merge_request: 11140 +author: diff --git a/changelogs/unreleased/27148-limit-bulk-create-memberships.yml b/changelogs/unreleased/27148-limit-bulk-create-memberships.yml new file mode 100644 index 00000000000..ac4aba2f4e0 --- /dev/null +++ b/changelogs/unreleased/27148-limit-bulk-create-memberships.yml @@ -0,0 +1,4 @@ +--- +title: Limit non-administrators to adding 100 members at a time to groups and projects +merge_request: 11940 +author: diff --git a/changelogs/unreleased/27439-memory-usage-info.yml b/changelogs/unreleased/27439-memory-usage-info.yml new file mode 100644 index 00000000000..dd212853f57 --- /dev/null +++ b/changelogs/unreleased/27439-memory-usage-info.yml @@ -0,0 +1,4 @@ +--- +title: Add performance deltas between app deployments on Merge Request widget +merge_request: 11730 +author: diff --git a/changelogs/unreleased/27614-improve-instant-comments-exp.yml b/changelogs/unreleased/27614-improve-instant-comments-exp.yml new file mode 100644 index 00000000000..4db676801f1 --- /dev/null +++ b/changelogs/unreleased/27614-improve-instant-comments-exp.yml @@ -0,0 +1,4 @@ +--- +title: Improve user experience around slash commands in instant comments +merge_request: 11612 +author: diff --git a/changelogs/unreleased/28080-system-checks.yml b/changelogs/unreleased/28080-system-checks.yml new file mode 100644 index 00000000000..7d83014279a --- /dev/null +++ b/changelogs/unreleased/28080-system-checks.yml @@ -0,0 +1,4 @@ +--- +title: Refactored gitlab:app:check into SystemCheck liberary and improve some checks +merge_request: 9173 +author: diff --git a/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml b/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml new file mode 100644 index 00000000000..2308a528580 --- /dev/null +++ b/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml @@ -0,0 +1,4 @@ +--- +title: Allow users to be hard-deleted from the admin panel +merge_request: 11874 +author: diff --git a/changelogs/unreleased/28694-hard-delete-user-from-api.yml b/changelogs/unreleased/28694-hard-delete-user-from-api.yml new file mode 100644 index 00000000000..ad46540495c --- /dev/null +++ b/changelogs/unreleased/28694-hard-delete-user-from-api.yml @@ -0,0 +1,4 @@ +--- +title: Allow users to be hard-deleted from the API +merge_request: 11853 +author: diff --git a/changelogs/unreleased/29118-add-prometheus-instrumenting-to-gitlab-webapp.yml b/changelogs/unreleased/29118-add-prometheus-instrumenting-to-gitlab-webapp.yml new file mode 100644 index 00000000000..99c55f128e3 --- /dev/null +++ b/changelogs/unreleased/29118-add-prometheus-instrumenting-to-gitlab-webapp.yml @@ -0,0 +1,4 @@ +--- +title: Add prometheus based metrics collection to gitlab webapp +merge_request: +author: diff --git a/changelogs/unreleased/29690-rotate-otp-key-base.yml b/changelogs/unreleased/29690-rotate-otp-key-base.yml new file mode 100644 index 00000000000..94d73a24758 --- /dev/null +++ b/changelogs/unreleased/29690-rotate-otp-key-base.yml @@ -0,0 +1,4 @@ +--- +title: Add a Rake task to aid in rotating otp_key_base +merge_request: 11881 +author: diff --git a/changelogs/unreleased/29852-latex-formatting.yml b/changelogs/unreleased/29852-latex-formatting.yml new file mode 100644 index 00000000000..e96cda1d6b3 --- /dev/null +++ b/changelogs/unreleased/29852-latex-formatting.yml @@ -0,0 +1,4 @@ +--- +title: Fix LaTeX formatting for AsciiDoc wiki +merge_request: 11212 +author: diff --git a/changelogs/unreleased/30378-simplified-repository-settings-page.yml b/changelogs/unreleased/30378-simplified-repository-settings-page.yml new file mode 100644 index 00000000000..e8b87c8bb33 --- /dev/null +++ b/changelogs/unreleased/30378-simplified-repository-settings-page.yml @@ -0,0 +1,4 @@ +--- +title: Simplify project repository settings page +merge_request: 11698 +author: diff --git a/changelogs/unreleased/30410-revert-9347-and-10079.yml b/changelogs/unreleased/30410-revert-9347-and-10079.yml new file mode 100644 index 00000000000..0149209caf2 --- /dev/null +++ b/changelogs/unreleased/30410-revert-9347-and-10079.yml @@ -0,0 +1,5 @@ +--- +title: Revert the feature that would include the current user's username in the HTTP + clone URL +merge_request: 11792 +author: diff --git a/changelogs/unreleased/30469-convdev-index.yml b/changelogs/unreleased/30469-convdev-index.yml new file mode 100644 index 00000000000..0bdd9c4a699 --- /dev/null +++ b/changelogs/unreleased/30469-convdev-index.yml @@ -0,0 +1,4 @@ +--- +title: Add ConvDev Index page to admin area +merge_request: 11377 +author: diff --git a/changelogs/unreleased/30651-improve-container-registry-description.yml b/changelogs/unreleased/30651-improve-container-registry-description.yml new file mode 100644 index 00000000000..0157c9885bc --- /dev/null +++ b/changelogs/unreleased/30651-improve-container-registry-description.yml @@ -0,0 +1,4 @@ +--- +title: Add changelog for improved Registry description +merge_request: 11816 +author: diff --git a/changelogs/unreleased/30827-changes-to-audit-log.yml b/changelogs/unreleased/30827-changes-to-audit-log.yml new file mode 100644 index 00000000000..32db3bf8e95 --- /dev/null +++ b/changelogs/unreleased/30827-changes-to-audit-log.yml @@ -0,0 +1,4 @@ +--- +title: Renamed users 'Audit Log'' to 'Authentication Log' +merge_request: 11400 +author: diff --git a/changelogs/unreleased/30892-add-api-support-for-pipeline-schedule.yml b/changelogs/unreleased/30892-add-api-support-for-pipeline-schedule.yml new file mode 100644 index 00000000000..26ce84697d0 --- /dev/null +++ b/changelogs/unreleased/30892-add-api-support-for-pipeline-schedule.yml @@ -0,0 +1,4 @@ +--- +title: Add API support for pipeline schedule +merge_request: 11307 +author: dosuken123 diff --git a/changelogs/unreleased/30917-wiki-is-not-searchable-with-guest-permissions.yml b/changelogs/unreleased/30917-wiki-is-not-searchable-with-guest-permissions.yml new file mode 100644 index 00000000000..c9bd2dc465e --- /dev/null +++ b/changelogs/unreleased/30917-wiki-is-not-searchable-with-guest-permissions.yml @@ -0,0 +1,4 @@ +--- +title: 'Fix: Wiki is not searchable with Guest permissions' +merge_request: +author: diff --git a/changelogs/unreleased/30949-empty-states.yml b/changelogs/unreleased/30949-empty-states.yml new file mode 100644 index 00000000000..bef87a954b7 --- /dev/null +++ b/changelogs/unreleased/30949-empty-states.yml @@ -0,0 +1,4 @@ +--- +title: Center all empty states +merge_request: +author: diff --git a/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml b/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml new file mode 100644 index 00000000000..e71910dbd67 --- /dev/null +++ b/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml @@ -0,0 +1,4 @@ +--- +title: Add slugify project path to CI enviroment variables +merge_request: 11838 +author: Ivan Chernov diff --git a/changelogs/unreleased/31384-new-issue-button-on-no-results-page-after-search-doesn-t-go-to-correct-form.yml b/changelogs/unreleased/31384-new-issue-button-on-no-results-page-after-search-doesn-t-go-to-correct-form.yml new file mode 100644 index 00000000000..8d586616e07 --- /dev/null +++ b/changelogs/unreleased/31384-new-issue-button-on-no-results-page-after-search-doesn-t-go-to-correct-form.yml @@ -0,0 +1,4 @@ +--- +title: Remove 'New issue' button when issues search returns no results. +merge_request: !11263 +author: diff --git a/changelogs/unreleased/31448-jira-urls.yml b/changelogs/unreleased/31448-jira-urls.yml new file mode 100644 index 00000000000..d0e39f61b55 --- /dev/null +++ b/changelogs/unreleased/31448-jira-urls.yml @@ -0,0 +1,4 @@ +--- +title: Add API URL to JIRA settings +merge_request: +author: diff --git a/changelogs/unreleased/31474-issue-boards-sidebar-milestone-dropdown-should-not-be-multi-select.yml b/changelogs/unreleased/31474-issue-boards-sidebar-milestone-dropdown-should-not-be-multi-select.yml new file mode 100644 index 00000000000..88e79e3b6ea --- /dev/null +++ b/changelogs/unreleased/31474-issue-boards-sidebar-milestone-dropdown-should-not-be-multi-select.yml @@ -0,0 +1,4 @@ +--- +title: Disallow multiple selections for Milestone dropdown +merge_request: 11084 +author: diff --git a/changelogs/unreleased/31483-ordered-task-list.yml b/changelogs/unreleased/31483-ordered-task-list.yml new file mode 100644 index 00000000000..c43915b3268 --- /dev/null +++ b/changelogs/unreleased/31483-ordered-task-list.yml @@ -0,0 +1,4 @@ +--- +title: Fix Ordered Task List Items +merge_request: 31483 +author: Jared Deckard <jared.deckard@gmail.com> diff --git a/changelogs/unreleased/31510-mask-password-field-edit.yml b/changelogs/unreleased/31510-mask-password-field-edit.yml new file mode 100644 index 00000000000..0ef37be328d --- /dev/null +++ b/changelogs/unreleased/31510-mask-password-field-edit.yml @@ -0,0 +1,4 @@ +--- +title: Update password field label while editing service settings +merge_request: 11431 +author: diff --git a/changelogs/unreleased/31511-jira-settings.yml b/changelogs/unreleased/31511-jira-settings.yml new file mode 100644 index 00000000000..4f9ddb13ef6 --- /dev/null +++ b/changelogs/unreleased/31511-jira-settings.yml @@ -0,0 +1,4 @@ +--- +title: Simplify testing and saving service integrations +merge_request: 11599 +author: diff --git a/changelogs/unreleased/31554-update-rufus-scheduler-and-sidekiq.yml b/changelogs/unreleased/31554-update-rufus-scheduler-and-sidekiq.yml new file mode 100644 index 00000000000..0a36b52d561 --- /dev/null +++ b/changelogs/unreleased/31554-update-rufus-scheduler-and-sidekiq.yml @@ -0,0 +1,5 @@ +--- +title: Update gem sidekiq-cron from 0.4.4 to 0.6.0 and rufus-scheduler from 3.1.10 + to 3.4.0 +merge_request: 10976 +author: dosuken123 diff --git a/changelogs/unreleased/31556-ci-coverage-paralel-rspec.yml b/changelogs/unreleased/31556-ci-coverage-paralel-rspec.yml new file mode 100644 index 00000000000..4137050a077 --- /dev/null +++ b/changelogs/unreleased/31556-ci-coverage-paralel-rspec.yml @@ -0,0 +1,4 @@ +--- +title: Fix the last coverage in trace log should be extracted +merge_request: 11128 +author: dosuken123 diff --git a/changelogs/unreleased/31602-display-whether-shared-runner-is-enabled-in-the-admin-dashboard.yml b/changelogs/unreleased/31602-display-whether-shared-runner-is-enabled-in-the-admin-dashboard.yml new file mode 100644 index 00000000000..00957f7e4f7 --- /dev/null +++ b/changelogs/unreleased/31602-display-whether-shared-runner-is-enabled-in-the-admin-dashboard.yml @@ -0,0 +1,4 @@ +--- +title: Display Shared Runner status in Admin Dashboard +merge_request: 11783 +author: Ivan Chernov diff --git a/changelogs/unreleased/31616-add-uptime-of-gitlab-instance-in-admin-area.yml b/changelogs/unreleased/31616-add-uptime-of-gitlab-instance-in-admin-area.yml new file mode 100644 index 00000000000..6dc48d6b2d8 --- /dev/null +++ b/changelogs/unreleased/31616-add-uptime-of-gitlab-instance-in-admin-area.yml @@ -0,0 +1,4 @@ +--- +title: Add server uptime to System Info page in admin dashboard +merge_request: 11590 +author: Justin Boltz diff --git a/changelogs/unreleased/31625-tag-editor-loses-all-inputs-when-you-try-to-add-a-tag-that-already-exists.yml b/changelogs/unreleased/31625-tag-editor-loses-all-inputs-when-you-try-to-add-a-tag-that-already-exists.yml new file mode 100644 index 00000000000..aae760b0ef5 --- /dev/null +++ b/changelogs/unreleased/31625-tag-editor-loses-all-inputs-when-you-try-to-add-a-tag-that-already-exists.yml @@ -0,0 +1,4 @@ +--- +title: Keep input data after creating a tag that already exists +merge_request: 11155 +author: diff --git a/changelogs/unreleased/31633-animate-issue.yml b/changelogs/unreleased/31633-animate-issue.yml new file mode 100644 index 00000000000..6df4135b09c --- /dev/null +++ b/changelogs/unreleased/31633-animate-issue.yml @@ -0,0 +1,4 @@ +--- +title: animate adding issue to boards +merge_request: +author: diff --git a/changelogs/unreleased/31644-make-cookie-sessions-unique.yml b/changelogs/unreleased/31644-make-cookie-sessions-unique.yml new file mode 100644 index 00000000000..e9a6a32cf70 --- /dev/null +++ b/changelogs/unreleased/31644-make-cookie-sessions-unique.yml @@ -0,0 +1,4 @@ +--- +title: Update session cookie key name to be unique to instance in development +merge_request: +author: diff --git a/changelogs/unreleased/31757-single-click-on-filter-in-search-bar-to-activate-dropdown.yml b/changelogs/unreleased/31757-single-click-on-filter-in-search-bar-to-activate-dropdown.yml new file mode 100644 index 00000000000..48b8a8507ec --- /dev/null +++ b/changelogs/unreleased/31757-single-click-on-filter-in-search-bar-to-activate-dropdown.yml @@ -0,0 +1,4 @@ +--- +title: Single click on filter to open filtered search dropdown +merge_request: +author: diff --git a/changelogs/unreleased/31781-print-rendered-files-not-possible.yml b/changelogs/unreleased/31781-print-rendered-files-not-possible.yml new file mode 100644 index 00000000000..14915823ff7 --- /dev/null +++ b/changelogs/unreleased/31781-print-rendered-files-not-possible.yml @@ -0,0 +1,4 @@ +--- +title: Include the blob content when printing a blob page +merge_request: 11247 +author: diff --git a/changelogs/unreleased/31840-add-a-rubocop-that-forbids-redirect_to-inside-a-controller-destroy-action-without-an-explicit-status.yml b/changelogs/unreleased/31840-add-a-rubocop-that-forbids-redirect_to-inside-a-controller-destroy-action-without-an-explicit-status.yml new file mode 100644 index 00000000000..52bfe771e2b --- /dev/null +++ b/changelogs/unreleased/31840-add-a-rubocop-that-forbids-redirect_to-inside-a-controller-destroy-action-without-an-explicit-status.yml @@ -0,0 +1,4 @@ +--- +title: Add a rubocop rule to check if a method 'redirect_to' is used without explicitly set 'status' in 'destroy' actions of controllers +merge_request: 11749 +author: @blackst0ne diff --git a/changelogs/unreleased/31849-pipeline-real-time-header.yml b/changelogs/unreleased/31849-pipeline-real-time-header.yml new file mode 100644 index 00000000000..2bb7af897ff --- /dev/null +++ b/changelogs/unreleased/31849-pipeline-real-time-header.yml @@ -0,0 +1,4 @@ +--- +title: Makes header information of pipeline show page realtine +merge_request: +author: diff --git a/changelogs/unreleased/31849-pipeline-show-view-realtime.yml b/changelogs/unreleased/31849-pipeline-show-view-realtime.yml new file mode 100644 index 00000000000..838a769a26e --- /dev/null +++ b/changelogs/unreleased/31849-pipeline-show-view-realtime.yml @@ -0,0 +1,5 @@ +--- +title: Creates a mediator for pipeline details vue in order to mount several vue apps + with the same data +merge_request: +author: diff --git a/changelogs/unreleased/31902-namespace-recent-searches-to-project.yml b/changelogs/unreleased/31902-namespace-recent-searches-to-project.yml new file mode 100644 index 00000000000..e00eb6d8f72 --- /dev/null +++ b/changelogs/unreleased/31902-namespace-recent-searches-to-project.yml @@ -0,0 +1,4 @@ +--- +title: Scope issue/merge request recent searches to project +merge_request: +author: diff --git a/changelogs/unreleased/3191-deploy-keys-update.yml b/changelogs/unreleased/3191-deploy-keys-update.yml new file mode 100644 index 00000000000..4100163e94f --- /dev/null +++ b/changelogs/unreleased/3191-deploy-keys-update.yml @@ -0,0 +1,4 @@ +--- +title: Implement ability to update deploy keys +merge_request: 10383 +author: Alexander Randa diff --git a/changelogs/unreleased/31943-document-go-183.yml b/changelogs/unreleased/31943-document-go-183.yml new file mode 100644 index 00000000000..201cd48f1ab --- /dev/null +++ b/changelogs/unreleased/31943-document-go-183.yml @@ -0,0 +1,3 @@ +--- +title: Upgrade dependency to Go 1.8.3 +merge_request: 31943 diff --git a/changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml b/changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml new file mode 100644 index 00000000000..f61aa0a6b6e --- /dev/null +++ b/changelogs/unreleased/31983-increase-merge-request-diff-file-size-limit-for-default-toggle-opening.yml @@ -0,0 +1,4 @@ +--- +title: Increase individual diff collapse limit to 100 KB, and render limit to 200 KB +merge_request: +author: diff --git a/changelogs/unreleased/31998-pipelines-empty-state.yml b/changelogs/unreleased/31998-pipelines-empty-state.yml new file mode 100644 index 00000000000..78ae222255e --- /dev/null +++ b/changelogs/unreleased/31998-pipelines-empty-state.yml @@ -0,0 +1,4 @@ +--- +title: Fix Pipelines table empty state - only render empty state if we receive 0 pipelines +merge_request: +author: diff --git a/changelogs/unreleased/32086-atwho-is-still-enabled-for-personal-snippet-comments-form.yml b/changelogs/unreleased/32086-atwho-is-still-enabled-for-personal-snippet-comments-form.yml new file mode 100644 index 00000000000..0fd248e0400 --- /dev/null +++ b/changelogs/unreleased/32086-atwho-is-still-enabled-for-personal-snippet-comments-form.yml @@ -0,0 +1,4 @@ +--- +title: Disable reference prefixes in notes for Snippets +merge_request: 11278 +author: diff --git a/changelogs/unreleased/32118-new-environment-btn-copy.yml b/changelogs/unreleased/32118-new-environment-btn-copy.yml new file mode 100644 index 00000000000..16a51c3db6a --- /dev/null +++ b/changelogs/unreleased/32118-new-environment-btn-copy.yml @@ -0,0 +1,4 @@ +--- +title: Make New environment empty state btn lowercase +merge_request: +author: diff --git a/changelogs/unreleased/32219-speed-up-yarn-install-in-ci-by-utilizing-inter-pipeline-cache.yml b/changelogs/unreleased/32219-speed-up-yarn-install-in-ci-by-utilizing-inter-pipeline-cache.yml new file mode 100644 index 00000000000..7fb3cb3a30b --- /dev/null +++ b/changelogs/unreleased/32219-speed-up-yarn-install-in-ci-by-utilizing-inter-pipeline-cache.yml @@ -0,0 +1,4 @@ +--- +title: Cache npm modules between pipelines with yarn to speed up setup-test-env +merge_request: 11343 +author: diff --git a/changelogs/unreleased/32340-correct-jobs-api-documentation b/changelogs/unreleased/32340-correct-jobs-api-documentation new file mode 100644 index 00000000000..4ada62356eb --- /dev/null +++ b/changelogs/unreleased/32340-correct-jobs-api-documentation @@ -0,0 +1,4 @@ +--- +title: "Correction to documention for manual steps on the Jobs API" +merge_request: 11411 +author: Zac Sturgess
\ No newline at end of file diff --git a/changelogs/unreleased/32395-duplicate-string-in-https-docs-gitlab-com-ce-administration-environment_variables-html.yml b/changelogs/unreleased/32395-duplicate-string-in-https-docs-gitlab-com-ce-administration-environment_variables-html.yml new file mode 100644 index 00000000000..d2be3d6cc4b --- /dev/null +++ b/changelogs/unreleased/32395-duplicate-string-in-https-docs-gitlab-com-ce-administration-environment_variables-html.yml @@ -0,0 +1,4 @@ +--- +title: Removes duplicate environment variable in documentation +merge_request: +author: diff --git a/changelogs/unreleased/32418-make-link-to-self-less-obvious.yml b/changelogs/unreleased/32418-make-link-to-self-less-obvious.yml new file mode 100644 index 00000000000..aabe87dac0f --- /dev/null +++ b/changelogs/unreleased/32418-make-link-to-self-less-obvious.yml @@ -0,0 +1,4 @@ +--- +title: Change links in issuable meta to black +merge_request: +author: diff --git a/changelogs/unreleased/32570-project-activity-tab-border.yml b/changelogs/unreleased/32570-project-activity-tab-border.yml new file mode 100644 index 00000000000..100a3e6a74d --- /dev/null +++ b/changelogs/unreleased/32570-project-activity-tab-border.yml @@ -0,0 +1,4 @@ +--- +title: Fix border-bottom for project activity tab +merge_request: +author: diff --git a/changelogs/unreleased/32598-avoid-resource-intensive-login-checks-if-password-is-not-provided-for-git-http.yml b/changelogs/unreleased/32598-avoid-resource-intensive-login-checks-if-password-is-not-provided-for-git-http.yml new file mode 100644 index 00000000000..6da7491bbda --- /dev/null +++ b/changelogs/unreleased/32598-avoid-resource-intensive-login-checks-if-password-is-not-provided-for-git-http.yml @@ -0,0 +1,4 @@ +--- +title: Avoid resource intensive login checks if password is not provided. +merge_request: 11537 +author: Horatiu Eugen Vlad diff --git a/changelogs/unreleased/32642_last_commit_id_in_file_api.yml b/changelogs/unreleased/32642_last_commit_id_in_file_api.yml new file mode 100644 index 00000000000..80435352e10 --- /dev/null +++ b/changelogs/unreleased/32642_last_commit_id_in_file_api.yml @@ -0,0 +1,4 @@ +--- +title: 'Introduce optimistic locking support via optional parameter last_commit_sha on File Update API' +merge_request: 11694 +author: electroma diff --git a/changelogs/unreleased/32682-skipped-ci-icon.yml b/changelogs/unreleased/32682-skipped-ci-icon.yml new file mode 100644 index 00000000000..ad498b51900 --- /dev/null +++ b/changelogs/unreleased/32682-skipped-ci-icon.yml @@ -0,0 +1,4 @@ +--- +title: Adds new icon for CI skipped status +merge_request: +author: diff --git a/changelogs/unreleased/32790-pipeline_schedules-pages-throwing-error-500.yml b/changelogs/unreleased/32790-pipeline_schedules-pages-throwing-error-500.yml new file mode 100644 index 00000000000..a58f3a7429e --- /dev/null +++ b/changelogs/unreleased/32790-pipeline_schedules-pages-throwing-error-500.yml @@ -0,0 +1,4 @@ +--- +title: Fix pipeline_schedules pages throwing error 500 +merge_request: 11706 +author: dosuken123 diff --git a/changelogs/unreleased/32799-remove-no_turbolink-attribute-from-haml.yml b/changelogs/unreleased/32799-remove-no_turbolink-attribute-from-haml.yml new file mode 100644 index 00000000000..9c1c1fe77f2 --- /dev/null +++ b/changelogs/unreleased/32799-remove-no_turbolink-attribute-from-haml.yml @@ -0,0 +1,4 @@ +--- +title: Remove redundant data-turbolink attributes from links +merge_request: 11672 +author: blackst0ne diff --git a/changelogs/unreleased/32807-company-icon.yml b/changelogs/unreleased/32807-company-icon.yml new file mode 100644 index 00000000000..718108d3733 --- /dev/null +++ b/changelogs/unreleased/32807-company-icon.yml @@ -0,0 +1,4 @@ +--- +title: Use briefcase icon for company in profile page +merge_request: +author: diff --git a/changelogs/unreleased/32832-confidential-issue-overflow.yml b/changelogs/unreleased/32832-confidential-issue-overflow.yml new file mode 100644 index 00000000000..7d3d3bfed2e --- /dev/null +++ b/changelogs/unreleased/32832-confidential-issue-overflow.yml @@ -0,0 +1,5 @@ +--- +title: Remove overflow from comment form for confidential issues and vertically aligns + confidential issue icon +merge_request: +author: diff --git a/changelogs/unreleased/32851-postgres-min-version.yml b/changelogs/unreleased/32851-postgres-min-version.yml new file mode 100644 index 00000000000..139307d65c6 --- /dev/null +++ b/changelogs/unreleased/32851-postgres-min-version.yml @@ -0,0 +1,4 @@ +--- +title: Minimum postgresql version is now 9.2 +merge_request: 11677 +author: diff --git a/changelogs/unreleased/32955-special-keywords.yml b/changelogs/unreleased/32955-special-keywords.yml new file mode 100644 index 00000000000..0f9939ced8c --- /dev/null +++ b/changelogs/unreleased/32955-special-keywords.yml @@ -0,0 +1,4 @@ +--- +title: Add all pipeline sources as special keywords to 'only' and 'except' +merge_request: 11844 +author: Filip Krakowski diff --git a/changelogs/unreleased/32983-merge-conflict-resolution-removed-the-newline-in-the-end-of-file.yml b/changelogs/unreleased/32983-merge-conflict-resolution-removed-the-newline-in-the-end-of-file.yml new file mode 100644 index 00000000000..eca42176501 --- /dev/null +++ b/changelogs/unreleased/32983-merge-conflict-resolution-removed-the-newline-in-the-end-of-file.yml @@ -0,0 +1,4 @@ +--- +title: Keep trailing newline when resolving conflicts by picking sides +merge_request: +author: diff --git a/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml b/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml new file mode 100644 index 00000000000..93037d6181e --- /dev/null +++ b/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml @@ -0,0 +1,4 @@ +--- +title: Use zopfli compression for frontend assets +merge_request: 11798 +author: diff --git a/changelogs/unreleased/32995-issue-contents-dynamically-replaced-with-stale-version-after-saving-or-refreshing-relative-external_url-only.yml b/changelogs/unreleased/32995-issue-contents-dynamically-replaced-with-stale-version-after-saving-or-refreshing-relative-external_url-only.yml new file mode 100644 index 00000000000..5cd36a4e3e2 --- /dev/null +++ b/changelogs/unreleased/32995-issue-contents-dynamically-replaced-with-stale-version-after-saving-or-refreshing-relative-external_url-only.yml @@ -0,0 +1,4 @@ +--- +title: Fix incorrect ETag cache key when relative instance URL is used +merge_request: 11964 +author: diff --git a/changelogs/unreleased/33000-tag-list-in-project-create-api.yml b/changelogs/unreleased/33000-tag-list-in-project-create-api.yml new file mode 100644 index 00000000000..b0d0d3cbeba --- /dev/null +++ b/changelogs/unreleased/33000-tag-list-in-project-create-api.yml @@ -0,0 +1,4 @@ +--- +title: Add tag_list param to project api +merge_request: 11799 +author: Ivan Chernov diff --git a/changelogs/unreleased/33032-invalid-you-directly-addressed-yourself-todo-when-using-unsubscribe.yml b/changelogs/unreleased/33032-invalid-you-directly-addressed-yourself-todo-when-using-unsubscribe.yml new file mode 100644 index 00000000000..1eaa0d0124e --- /dev/null +++ b/changelogs/unreleased/33032-invalid-you-directly-addressed-yourself-todo-when-using-unsubscribe.yml @@ -0,0 +1,5 @@ +--- +title: Fix /unsubscribe slash command creating extra todos when you were already mentioned + in an issue +merge_request: +author: diff --git a/changelogs/unreleased/33048-markdown-rendering-of-md-files-has-ceased-to-display-latex-equations.yml b/changelogs/unreleased/33048-markdown-rendering-of-md-files-has-ceased-to-display-latex-equations.yml new file mode 100644 index 00000000000..5648e013e75 --- /dev/null +++ b/changelogs/unreleased/33048-markdown-rendering-of-md-files-has-ceased-to-display-latex-equations.yml @@ -0,0 +1,4 @@ +--- +title: Fix math rendering on blob pages +merge_request: +author: diff --git a/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml b/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml new file mode 100644 index 00000000000..3b98525167d --- /dev/null +++ b/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml @@ -0,0 +1,4 @@ +--- +title: Allow group reporters to manage group labels +merge_request: +author: diff --git a/changelogs/unreleased/33207-show-delete-option-in-admin-users-page.yml b/changelogs/unreleased/33207-show-delete-option-in-admin-users-page.yml new file mode 100644 index 00000000000..5eb4e15e311 --- /dev/null +++ b/changelogs/unreleased/33207-show-delete-option-in-admin-users-page.yml @@ -0,0 +1,4 @@ +--- +title: Allow admins to delete users from the admin users page +merge_request: 11852 +author: diff --git a/changelogs/unreleased/33215-fix-hard-delete-of-users.yml b/changelogs/unreleased/33215-fix-hard-delete-of-users.yml new file mode 100644 index 00000000000..29699ff745a --- /dev/null +++ b/changelogs/unreleased/33215-fix-hard-delete-of-users.yml @@ -0,0 +1,4 @@ +--- +title: Fix hard-deleting users when they have authored issues +merge_request: 11855 +author: diff --git a/changelogs/unreleased/33242-create-project-for-user-api-ignores-path-parameter.yml b/changelogs/unreleased/33242-create-project-for-user-api-ignores-path-parameter.yml new file mode 100644 index 00000000000..c33278998ee --- /dev/null +++ b/changelogs/unreleased/33242-create-project-for-user-api-ignores-path-parameter.yml @@ -0,0 +1,4 @@ +--- +title: Fix missing optional path parameter in "Create project for user" API +merge_request: 11868 +author: diff --git a/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml b/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml new file mode 100644 index 00000000000..07dd0872d3b --- /dev/null +++ b/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml @@ -0,0 +1,4 @@ +--- +title: Add Chinese translation of Cycle Analytics Page to I18N +merge_request: 11644 +author:Huang Tao diff --git a/changelogs/unreleased/33308-use-pre-wrap-for-commit-messages.yml b/changelogs/unreleased/33308-use-pre-wrap-for-commit-messages.yml new file mode 100644 index 00000000000..43e8f242947 --- /dev/null +++ b/changelogs/unreleased/33308-use-pre-wrap-for-commit-messages.yml @@ -0,0 +1,4 @@ +--- +title: Use pre-wrap for commit messages to keep lists indented +merge_request: +author: diff --git a/changelogs/unreleased/UI-improvements-for-count-badges-and-permission-badges.yml b/changelogs/unreleased/UI-improvements-for-count-badges-and-permission-badges.yml new file mode 100644 index 00000000000..374f643faa7 --- /dev/null +++ b/changelogs/unreleased/UI-improvements-for-count-badges-and-permission-badges.yml @@ -0,0 +1,5 @@ +--- +title: Count badges depend on translucent color to better adjust to different background + colors and permission badges now feature a pill shaped design similar to labels +merge_request: +author: diff --git a/changelogs/unreleased/adam-influxdb-hostname.yml b/changelogs/unreleased/adam-influxdb-hostname.yml new file mode 100644 index 00000000000..ab201ae7894 --- /dev/null +++ b/changelogs/unreleased/adam-influxdb-hostname.yml @@ -0,0 +1,4 @@ +--- +title: Allow GitLab instance to start when InfluxDB hostname cannot be resolved +merge_request: 11356 +author: diff --git a/changelogs/unreleased/add-index-for-auto_canceled_by_id-mysql.yml b/changelogs/unreleased/add-index-for-auto_canceled_by_id-mysql.yml new file mode 100644 index 00000000000..eac78e9ee1f --- /dev/null +++ b/changelogs/unreleased/add-index-for-auto_canceled_by_id-mysql.yml @@ -0,0 +1,4 @@ +--- +title: Add indices for auto_canceled_by_id for ci_pipelines and ci_builds on PostgreSQL +merge_request: 11034 +author: diff --git a/changelogs/unreleased/add-unicode-trace-feature-test.yml b/changelogs/unreleased/add-unicode-trace-feature-test.yml new file mode 100644 index 00000000000..90c6a9afefc --- /dev/null +++ b/changelogs/unreleased/add-unicode-trace-feature-test.yml @@ -0,0 +1,4 @@ +--- +title: Add a feature test for Unicode trace +merge_request: 10736 +author: dosuken123 diff --git a/changelogs/unreleased/add_ability_to_cancel_attaching_file_and_redesign_attaching_files_ui.yml b/changelogs/unreleased/add_ability_to_cancel_attaching_file_and_redesign_attaching_files_ui.yml new file mode 100644 index 00000000000..fcf4efa2846 --- /dev/null +++ b/changelogs/unreleased/add_ability_to_cancel_attaching_file_and_redesign_attaching_files_ui.yml @@ -0,0 +1,4 @@ +--- +title: Add an ability to cancel attaching file and redesign attaching files UI +merge_request: 9431 +author: blackst0ne diff --git a/changelogs/unreleased/aliyun-backup-provider.yml b/changelogs/unreleased/aliyun-backup-provider.yml new file mode 100644 index 00000000000..e7505e44a59 --- /dev/null +++ b/changelogs/unreleased/aliyun-backup-provider.yml @@ -0,0 +1,4 @@ +--- +title: Add Aliyun OSS as the backup storage provider +merge_request: 9721 +author: Yuanfei Zhu diff --git a/changelogs/unreleased/allow_numeric_pages_domain.yml b/changelogs/unreleased/allow_numeric_pages_domain.yml new file mode 100644 index 00000000000..10d9f26f88d --- /dev/null +++ b/changelogs/unreleased/allow_numeric_pages_domain.yml @@ -0,0 +1,4 @@ +--- +title: Allow numeric pages domain +merge_request: 11550 +author: diff --git a/changelogs/unreleased/allow_numeric_values_in_gitlab_ci_yml.yml b/changelogs/unreleased/allow_numeric_values_in_gitlab_ci_yml.yml new file mode 100644 index 00000000000..8c7fa53a18b --- /dev/null +++ b/changelogs/unreleased/allow_numeric_values_in_gitlab_ci_yml.yml @@ -0,0 +1,4 @@ +--- +title: Allow numeric values in gitlab-ci.yml +merge_request: 10607 +author: blackst0ne diff --git a/changelogs/unreleased/auto-search-when-state-changed.yml b/changelogs/unreleased/auto-search-when-state-changed.yml new file mode 100644 index 00000000000..2723beb8600 --- /dev/null +++ b/changelogs/unreleased/auto-search-when-state-changed.yml @@ -0,0 +1,4 @@ +--- +title: Perform filtered search when state tab is changed +merge_request: +author: diff --git a/changelogs/unreleased/bugfix-v3-deploy_keys-can_push.yml b/changelogs/unreleased/bugfix-v3-deploy_keys-can_push.yml new file mode 100644 index 00000000000..0306663ac8d --- /dev/null +++ b/changelogs/unreleased/bugfix-v3-deploy_keys-can_push.yml @@ -0,0 +1,4 @@ +--- +title: "Fixed handling of the `can_push` attribute in the v3 deploy_keys api" +merge_request: 11607 +author: Richard Clamp diff --git a/changelogs/unreleased/bvl-rename-build-events-to-job-events.yml b/changelogs/unreleased/bvl-rename-build-events-to-job-events.yml new file mode 100644 index 00000000000..2ce01a71361 --- /dev/null +++ b/changelogs/unreleased/bvl-rename-build-events-to-job-events.yml @@ -0,0 +1,4 @@ +--- +title: Rename build_events to job_events +merge_request: 11287 +author: diff --git a/changelogs/unreleased/bvl-translate-project-pages.yml b/changelogs/unreleased/bvl-translate-project-pages.yml new file mode 100644 index 00000000000..fb90aba08b4 --- /dev/null +++ b/changelogs/unreleased/bvl-translate-project-pages.yml @@ -0,0 +1,4 @@ +--- +title: Translate backend for Project & Repository pages +merge_request: 11183 +author: diff --git a/changelogs/unreleased/ce-32623-browser-tooltip-commits-branch-list.yml b/changelogs/unreleased/ce-32623-browser-tooltip-commits-branch-list.yml new file mode 100644 index 00000000000..93edafed699 --- /dev/null +++ b/changelogs/unreleased/ce-32623-browser-tooltip-commits-branch-list.yml @@ -0,0 +1,5 @@ +--- +title: Change order of commits ahead and behind on divergence graph for branch list + view +merge_request: +author: diff --git a/changelogs/unreleased/ci-build-pipeline-header-vue.yml b/changelogs/unreleased/ci-build-pipeline-header-vue.yml new file mode 100644 index 00000000000..2bbff2fdd16 --- /dev/null +++ b/changelogs/unreleased/ci-build-pipeline-header-vue.yml @@ -0,0 +1,4 @@ +--- +title: Creates CI Header component for Pipelines and Jobs details pages +merge_request: +author: diff --git a/changelogs/unreleased/counters_cache_invalidation.yml b/changelogs/unreleased/counters_cache_invalidation.yml new file mode 100644 index 00000000000..1e78765ec10 --- /dev/null +++ b/changelogs/unreleased/counters_cache_invalidation.yml @@ -0,0 +1,4 @@ +--- +title: Invalidate cache for issue and MR counters more granularly +merge_request: +author: diff --git a/changelogs/unreleased/dm-async-tree-readme.yml b/changelogs/unreleased/dm-async-tree-readme.yml new file mode 100644 index 00000000000..fb1cfeb210a --- /dev/null +++ b/changelogs/unreleased/dm-async-tree-readme.yml @@ -0,0 +1,4 @@ +--- +title: Load tree readme asynchronously +merge_request: +author: diff --git a/changelogs/unreleased/dm-auxiliary-viewers.yml b/changelogs/unreleased/dm-auxiliary-viewers.yml new file mode 100644 index 00000000000..ba73a499115 --- /dev/null +++ b/changelogs/unreleased/dm-auxiliary-viewers.yml @@ -0,0 +1,5 @@ +--- +title: Display extra info about files on .gitlab-ci.yml, .gitlab/route-map.yml and + LICENSE blob pages +merge_request: +author: diff --git a/changelogs/unreleased/dm-comment-on-mr-commit-discussion.yml b/changelogs/unreleased/dm-comment-on-mr-commit-discussion.yml new file mode 100644 index 00000000000..50db66c89ba --- /dev/null +++ b/changelogs/unreleased/dm-comment-on-mr-commit-discussion.yml @@ -0,0 +1,4 @@ +--- +title: Fix replying to a commit discussion displayed in the context of an MR +merge_request: +author: diff --git a/changelogs/unreleased/dm-consistent-commit-sha-style.yml b/changelogs/unreleased/dm-consistent-commit-sha-style.yml new file mode 100644 index 00000000000..b6dace34d9b --- /dev/null +++ b/changelogs/unreleased/dm-consistent-commit-sha-style.yml @@ -0,0 +1,4 @@ +--- +title: Consistently use monospace font for commit SHAs and branch and tag names +merge_request: +author: diff --git a/changelogs/unreleased/dm-consistent-last-push-event.yml b/changelogs/unreleased/dm-consistent-last-push-event.yml new file mode 100644 index 00000000000..acc17cb4523 --- /dev/null +++ b/changelogs/unreleased/dm-consistent-last-push-event.yml @@ -0,0 +1,4 @@ +--- +title: Consistently display last push event widget +merge_request: +author: diff --git a/changelogs/unreleased/dm-copy-as-gfm-without-empty-elements.yml b/changelogs/unreleased/dm-copy-as-gfm-without-empty-elements.yml new file mode 100644 index 00000000000..45a61320ff2 --- /dev/null +++ b/changelogs/unreleased/dm-copy-as-gfm-without-empty-elements.yml @@ -0,0 +1,4 @@ +--- +title: Don't copy empty elements that were not selected on purpose as GFM +merge_request: +author: diff --git a/changelogs/unreleased/dm-copy-gfm-when-parts-of-other-elements-are-selected.yml b/changelogs/unreleased/dm-copy-gfm-when-parts-of-other-elements-are-selected.yml new file mode 100644 index 00000000000..ae916c30ff8 --- /dev/null +++ b/changelogs/unreleased/dm-copy-gfm-when-parts-of-other-elements-are-selected.yml @@ -0,0 +1,4 @@ +--- +title: Copy as GFM even when parts of other elements are selected +merge_request: +author: diff --git a/changelogs/unreleased/dm-dependency-linker-gemfile.yml b/changelogs/unreleased/dm-dependency-linker-gemfile.yml new file mode 100644 index 00000000000..2d4167a1be5 --- /dev/null +++ b/changelogs/unreleased/dm-dependency-linker-gemfile.yml @@ -0,0 +1,4 @@ +--- +title: Autolink package names in Gemfile +merge_request: +author: diff --git a/changelogs/unreleased/dm-discussions-n-plus-1.yml b/changelogs/unreleased/dm-discussions-n-plus-1.yml new file mode 100644 index 00000000000..b97e4344248 --- /dev/null +++ b/changelogs/unreleased/dm-discussions-n-plus-1.yml @@ -0,0 +1,4 @@ +--- +title: Resolve N+1 query issue with discussions +merge_request: +author: diff --git a/changelogs/unreleased/dm-emails-are-not-user-references.yml b/changelogs/unreleased/dm-emails-are-not-user-references.yml new file mode 100644 index 00000000000..fe55a75a88f --- /dev/null +++ b/changelogs/unreleased/dm-emails-are-not-user-references.yml @@ -0,0 +1,4 @@ +--- +title: Don't match email addresses or foo@bar as user references +merge_request: +author: diff --git a/changelogs/unreleased/dm-fix-jump-button.yml b/changelogs/unreleased/dm-fix-jump-button.yml new file mode 100644 index 00000000000..4cde354fa28 --- /dev/null +++ b/changelogs/unreleased/dm-fix-jump-button.yml @@ -0,0 +1,4 @@ +--- +title: Fix title of discussion jump button at top of page +merge_request: +author: diff --git a/changelogs/unreleased/dm-gitmodules-parsing.yml b/changelogs/unreleased/dm-gitmodules-parsing.yml new file mode 100644 index 00000000000..a7d755d6c4d --- /dev/null +++ b/changelogs/unreleased/dm-gitmodules-parsing.yml @@ -0,0 +1,4 @@ +--- +title: Make .gitmodules parsing more resilient to syntax errors +merge_request: +author: diff --git a/changelogs/unreleased/dm-gravatar-username.yml b/changelogs/unreleased/dm-gravatar-username.yml new file mode 100644 index 00000000000..d50455061ec --- /dev/null +++ b/changelogs/unreleased/dm-gravatar-username.yml @@ -0,0 +1,4 @@ +--- +title: Add username parameter to gravatar URL +merge_request: +author: diff --git a/changelogs/unreleased/dm-more-dependency-linkers.yml b/changelogs/unreleased/dm-more-dependency-linkers.yml new file mode 100644 index 00000000000..12d45e71e85 --- /dev/null +++ b/changelogs/unreleased/dm-more-dependency-linkers.yml @@ -0,0 +1,4 @@ +--- +title: Autolink package names in more dependency files +merge_request: +author: diff --git a/changelogs/unreleased/dm-oauth-config-for.yml b/changelogs/unreleased/dm-oauth-config-for.yml new file mode 100644 index 00000000000..8fbbd45bb57 --- /dev/null +++ b/changelogs/unreleased/dm-oauth-config-for.yml @@ -0,0 +1,4 @@ +--- +title: Return nil when looking up config for unknown LDAP provider +merge_request: +author: diff --git a/changelogs/unreleased/dm-outdated-system-note.yml b/changelogs/unreleased/dm-outdated-system-note.yml new file mode 100644 index 00000000000..a1038a1051b --- /dev/null +++ b/changelogs/unreleased/dm-outdated-system-note.yml @@ -0,0 +1,4 @@ +--- +title: Add system note with link to diff comparison when MR discussion becomes outdated +merge_request: +author: diff --git a/changelogs/unreleased/dm-paste-code-inside-gfm-code.yml b/changelogs/unreleased/dm-paste-code-inside-gfm-code.yml new file mode 100644 index 00000000000..d078ca449a5 --- /dev/null +++ b/changelogs/unreleased/dm-paste-code-inside-gfm-code.yml @@ -0,0 +1,4 @@ +--- +title: Don't wrap pasted code when it's already inside code tags +merge_request: +author: diff --git a/changelogs/unreleased/dm-tree-last-commit.yml b/changelogs/unreleased/dm-tree-last-commit.yml new file mode 100644 index 00000000000..50619fd6ef2 --- /dev/null +++ b/changelogs/unreleased/dm-tree-last-commit.yml @@ -0,0 +1,4 @@ +--- +title: Show last commit for current tree on tree page +merge_request: +author: diff --git a/changelogs/unreleased/document-foreign-keys.yml b/changelogs/unreleased/document-foreign-keys.yml new file mode 100644 index 00000000000..faa467e8185 --- /dev/null +++ b/changelogs/unreleased/document-foreign-keys.yml @@ -0,0 +1,4 @@ +--- +title: Add documentation about adding foreign keys +merge_request: +author: diff --git a/changelogs/unreleased/dturner-username.yml b/changelogs/unreleased/dturner-username.yml new file mode 100644 index 00000000000..09ba822ee65 --- /dev/null +++ b/changelogs/unreleased/dturner-username.yml @@ -0,0 +1,4 @@ +--- +title: add username field to push webhook +merge_request: +author: David Turner diff --git a/changelogs/unreleased/dz-fix-submodule-subgroup.yml b/changelogs/unreleased/dz-fix-submodule-subgroup.yml new file mode 100644 index 00000000000..20c7c9ce657 --- /dev/null +++ b/changelogs/unreleased/dz-fix-submodule-subgroup.yml @@ -0,0 +1,4 @@ +--- +title: Fix submodule link to then project under subgroup +merge_request: 11906 +author: diff --git a/changelogs/unreleased/dz-project-list-cache-key.yml b/changelogs/unreleased/dz-project-list-cache-key.yml new file mode 100644 index 00000000000..9e4826e686a --- /dev/null +++ b/changelogs/unreleased/dz-project-list-cache-key.yml @@ -0,0 +1,4 @@ +--- +title: Use route.cache_key for project list cache key +merge_request: 11325 +author: diff --git a/changelogs/unreleased/dz-rename-pipelines-settings-tab.yml b/changelogs/unreleased/dz-rename-pipelines-settings-tab.yml new file mode 100644 index 00000000000..6a1232523bb --- /dev/null +++ b/changelogs/unreleased/dz-rename-pipelines-settings-tab.yml @@ -0,0 +1,4 @@ +--- +title: Rename CI/CD Pipelines to Pipelines in the project settings +merge_request: +author: diff --git a/changelogs/unreleased/enable-auto-cancelling-by-default.yml b/changelogs/unreleased/enable-auto-cancelling-by-default.yml new file mode 100644 index 00000000000..8b1659bf38b --- /dev/null +++ b/changelogs/unreleased/enable-auto-cancelling-by-default.yml @@ -0,0 +1,4 @@ +--- +title: Enable cancelling non-HEAD pending pipelines by default for all projects +merge_request: 11023 +author: diff --git a/changelogs/unreleased/environment-detail-view.yml b/changelogs/unreleased/environment-detail-view.yml new file mode 100644 index 00000000000..c74f70ea86d --- /dev/null +++ b/changelogs/unreleased/environment-detail-view.yml @@ -0,0 +1,4 @@ +--- +title: Make environment tables responsive +merge_request: +author: diff --git a/changelogs/unreleased/expand-backlog-closed-lists-issue-boards.yml b/changelogs/unreleased/expand-backlog-closed-lists-issue-boards.yml new file mode 100644 index 00000000000..4796f8e918b --- /dev/null +++ b/changelogs/unreleased/expand-backlog-closed-lists-issue-boards.yml @@ -0,0 +1,4 @@ +--- +title: Expand/collapse backlog & closed lists in issue boards +merge_request: +author: diff --git a/changelogs/unreleased/feature-flags-flipper.yml b/changelogs/unreleased/feature-flags-flipper.yml new file mode 100644 index 00000000000..5be5c44166d --- /dev/null +++ b/changelogs/unreleased/feature-flags-flipper.yml @@ -0,0 +1,4 @@ +--- +title: Add feature toggles and API endpoints for admins +merge_request: 11747 +author: diff --git a/changelogs/unreleased/feature-gb-persist-pipeline-stages.yml b/changelogs/unreleased/feature-gb-persist-pipeline-stages.yml new file mode 100644 index 00000000000..1404b342359 --- /dev/null +++ b/changelogs/unreleased/feature-gb-persist-pipeline-stages.yml @@ -0,0 +1,4 @@ +--- +title: Persist pipeline stages in the database +merge_request: 11790 +author: diff --git a/changelogs/unreleased/feature-print-go-version-in-env-info.yml b/changelogs/unreleased/feature-print-go-version-in-env-info.yml new file mode 100644 index 00000000000..34c19b06eda --- /dev/null +++ b/changelogs/unreleased/feature-print-go-version-in-env-info.yml @@ -0,0 +1,4 @@ +--- +title: Print Go version in rake gitlab:env:info +merge_request: 11241 +author: diff --git a/changelogs/unreleased/feature-rss-scoped-token.yml b/changelogs/unreleased/feature-rss-scoped-token.yml new file mode 100644 index 00000000000..740d8778be2 --- /dev/null +++ b/changelogs/unreleased/feature-rss-scoped-token.yml @@ -0,0 +1,4 @@ +--- +title: Expose atom links with an RSS token instead of using the private token +merge_request: 11647 +author: Alexis Reigel diff --git a/changelogs/unreleased/fix-backup-restore-resume.yml b/changelogs/unreleased/fix-backup-restore-resume.yml new file mode 100644 index 00000000000..b7dfd451f5d --- /dev/null +++ b/changelogs/unreleased/fix-backup-restore-resume.yml @@ -0,0 +1,4 @@ +--- +title: Make backup task to continue on corrupt repositories +merge_request: 11962 +author: diff --git a/changelogs/unreleased/fix-counter-cache-for-acts-as-taggable.yml b/changelogs/unreleased/fix-counter-cache-for-acts-as-taggable.yml new file mode 100644 index 00000000000..e40668546c0 --- /dev/null +++ b/changelogs/unreleased/fix-counter-cache-for-acts-as-taggable.yml @@ -0,0 +1,4 @@ +--- +title: Fix counter cache for acts as taggable +merge_request: +author: diff --git a/changelogs/unreleased/fix-encoding-binary-issue.yml b/changelogs/unreleased/fix-encoding-binary-issue.yml new file mode 100644 index 00000000000..ac9aff64a88 --- /dev/null +++ b/changelogs/unreleased/fix-encoding-binary-issue.yml @@ -0,0 +1,4 @@ +--- +title: Fix binary encoding error on MR diffs +merge_request: 11929 +author: diff --git a/changelogs/unreleased/fix-gb-exclude-manual-actions-from-cancelable-jobs.yml b/changelogs/unreleased/fix-gb-exclude-manual-actions-from-cancelable-jobs.yml new file mode 100644 index 00000000000..a16fc775b5e --- /dev/null +++ b/changelogs/unreleased/fix-gb-exclude-manual-actions-from-cancelable-jobs.yml @@ -0,0 +1,4 @@ +--- +title: Exclude manual actions when checking if pipeline can be canceled +merge_request: 11562 +author: diff --git a/changelogs/unreleased/fix-gb-use-merge-ability-for-protected-manual-actions.yml b/changelogs/unreleased/fix-gb-use-merge-ability-for-protected-manual-actions.yml new file mode 100644 index 00000000000..43c18502cd6 --- /dev/null +++ b/changelogs/unreleased/fix-gb-use-merge-ability-for-protected-manual-actions.yml @@ -0,0 +1,4 @@ +--- +title: Respect merge, instead of push, permissions for protected actions +merge_request: 11648 +author: diff --git a/changelogs/unreleased/fix-github-import.yml b/changelogs/unreleased/fix-github-import.yml new file mode 100644 index 00000000000..3a57152f7a8 --- /dev/null +++ b/changelogs/unreleased/fix-github-import.yml @@ -0,0 +1,4 @@ +--- +title: Fix token interpolation when setting the Github remote +merge_request: +author: diff --git a/changelogs/unreleased/fix-n-plus-one-queries-for-user-access.yml b/changelogs/unreleased/fix-n-plus-one-queries-for-user-access.yml new file mode 100644 index 00000000000..c2671a96b83 --- /dev/null +++ b/changelogs/unreleased/fix-n-plus-one-queries-for-user-access.yml @@ -0,0 +1,4 @@ +--- +title: Fix N+1 queries for non-members in comment threads +merge_request: +author: diff --git a/changelogs/unreleased/fix-terminals-support-for-kubernetes-service.yml b/changelogs/unreleased/fix-terminals-support-for-kubernetes-service.yml new file mode 100644 index 00000000000..fb91da9510c --- /dev/null +++ b/changelogs/unreleased/fix-terminals-support-for-kubernetes-service.yml @@ -0,0 +1,4 @@ +--- +title: Fix terminals support for Kubernetes Service +merge_request: +author: diff --git a/changelogs/unreleased/fix_commits_page.yml b/changelogs/unreleased/fix_commits_page.yml new file mode 100644 index 00000000000..a2afaf6e626 --- /dev/null +++ b/changelogs/unreleased/fix_commits_page.yml @@ -0,0 +1,4 @@ +--- +title: Fix duplication of commits header on commits page +merge_request: 11006 +author: @blackst0ne diff --git a/changelogs/unreleased/fix_diff_line_comments.yml b/changelogs/unreleased/fix_diff_line_comments.yml new file mode 100644 index 00000000000..bdb0539b49d --- /dev/null +++ b/changelogs/unreleased/fix_diff_line_comments.yml @@ -0,0 +1,5 @@ +--- +title: 'Fix: A diff comment on a change at last line of a file shows as two comments + in discussion' +merge_request: +author: diff --git a/changelogs/unreleased/fixed-confidential-issue-bar.yml b/changelogs/unreleased/fixed-confidential-issue-bar.yml new file mode 100644 index 00000000000..6a41590d0af --- /dev/null +++ b/changelogs/unreleased/fixed-confidential-issue-bar.yml @@ -0,0 +1,4 @@ +--- +title: Make confidential issues more obviously confidential +merge_request: +author: diff --git a/changelogs/unreleased/gitaly-local-branches.yml b/changelogs/unreleased/gitaly-local-branches.yml new file mode 100644 index 00000000000..adcc0fa6280 --- /dev/null +++ b/changelogs/unreleased/gitaly-local-branches.yml @@ -0,0 +1,4 @@ +--- +title: Add suport for find_local_branches GRPC from Gitaly +merge_request: 10059 +author: diff --git a/changelogs/unreleased/gitaly-opt-out.yml b/changelogs/unreleased/gitaly-opt-out.yml new file mode 100644 index 00000000000..2f89e0bfc9a --- /dev/null +++ b/changelogs/unreleased/gitaly-opt-out.yml @@ -0,0 +1,4 @@ +--- +title: Enable Gitaly by default in installations from source +merge_request: 11796 +author: diff --git a/changelogs/unreleased/introduce-source-to-pipelines.yml b/changelogs/unreleased/introduce-source-to-pipelines.yml new file mode 100644 index 00000000000..7898bd31b39 --- /dev/null +++ b/changelogs/unreleased/introduce-source-to-pipelines.yml @@ -0,0 +1,4 @@ +--- +title: Introduce source to Pipeline entity +merge_request: +author: diff --git a/changelogs/unreleased/issuable-form-create-label-sub-groups.yml b/changelogs/unreleased/issuable-form-create-label-sub-groups.yml new file mode 100644 index 00000000000..54b818d6d5e --- /dev/null +++ b/changelogs/unreleased/issuable-form-create-label-sub-groups.yml @@ -0,0 +1,4 @@ +--- +title: Fixed create new label form in issue form not working for sub-group projects +merge_request: +author: diff --git a/changelogs/unreleased/issue-23254.yml b/changelogs/unreleased/issue-23254.yml new file mode 100644 index 00000000000..568a7a41c30 --- /dev/null +++ b/changelogs/unreleased/issue-23254.yml @@ -0,0 +1,4 @@ +--- +title: Fixed style on unsubscribe page +merge_request: +author: Gustav Ernberg diff --git a/changelogs/unreleased/issue-edit-inline.yml b/changelogs/unreleased/issue-edit-inline.yml new file mode 100644 index 00000000000..db03d1bdac4 --- /dev/null +++ b/changelogs/unreleased/issue-edit-inline.yml @@ -0,0 +1,4 @@ +--- +title: Enables inline editing for an issues title & description +merge_request: +author: diff --git a/changelogs/unreleased/issue-template-reproduce-in-example-project.yml b/changelogs/unreleased/issue-template-reproduce-in-example-project.yml new file mode 100644 index 00000000000..8116007b459 --- /dev/null +++ b/changelogs/unreleased/issue-template-reproduce-in-example-project.yml @@ -0,0 +1,4 @@ +--- +title: Ask for an example project for bug reports +merge_request: +author: diff --git a/changelogs/unreleased/issue-templates-summary-lines.yml b/changelogs/unreleased/issue-templates-summary-lines.yml new file mode 100644 index 00000000000..0c8c3d884ce --- /dev/null +++ b/changelogs/unreleased/issue-templates-summary-lines.yml @@ -0,0 +1,4 @@ +--- +title: Add summary lines for collapsed details in the bug report template +merge_request: +author: diff --git a/changelogs/unreleased/issue_19262.yml b/changelogs/unreleased/issue_19262.yml new file mode 100644 index 00000000000..7bcbc647fcb --- /dev/null +++ b/changelogs/unreleased/issue_19262.yml @@ -0,0 +1,4 @@ +--- +title: Prevent commits from upstream repositories to be re-processed by forks +merge_request: +author: diff --git a/changelogs/unreleased/issue_27166_2.yml b/changelogs/unreleased/issue_27166_2.yml new file mode 100644 index 00000000000..9b9906e03dd --- /dev/null +++ b/changelogs/unreleased/issue_27166_2.yml @@ -0,0 +1,4 @@ +--- +title: Avoid repeated queries for pipeline builds on merge requests +merge_request: +author: diff --git a/changelogs/unreleased/issue_27168_2.yml b/changelogs/unreleased/issue_27168_2.yml new file mode 100644 index 00000000000..c67692493e0 --- /dev/null +++ b/changelogs/unreleased/issue_27168_2.yml @@ -0,0 +1,4 @@ +--- +title: Preloads head pipeline for merge request collection +merge_request: +author: diff --git a/changelogs/unreleased/issue_32225_2.yml b/changelogs/unreleased/issue_32225_2.yml new file mode 100644 index 00000000000..320b9fe00b8 --- /dev/null +++ b/changelogs/unreleased/issue_32225_2.yml @@ -0,0 +1,4 @@ +--- +title: Handle head pipeline when creating merge requests +merge_request: +author: diff --git a/changelogs/unreleased/jouve-gitlab-ce-admin_keys.yml b/changelogs/unreleased/jouve-gitlab-ce-admin_keys.yml new file mode 100644 index 00000000000..df4de9f4e21 --- /dev/null +++ b/changelogs/unreleased/jouve-gitlab-ce-admin_keys.yml @@ -0,0 +1,5 @@ +--- +title: Redirect to user's keys index instead of user's index after a key is deleted + in the admin +merge_request: 10227 +author: Cyril Jouve diff --git a/changelogs/unreleased/mabes-gitlab-ce-bypass-auto-login.yml b/changelogs/unreleased/mabes-gitlab-ce-bypass-auto-login.yml new file mode 100644 index 00000000000..a321ed9d7d8 --- /dev/null +++ b/changelogs/unreleased/mabes-gitlab-ce-bypass-auto-login.yml @@ -0,0 +1,4 @@ +--- +title: Allow manual bypass of auto_sign_in_with_provider with a new param +merge_request: 10187 +author: Maxime Besson diff --git a/changelogs/unreleased/migrate-artifacts-to-a-new-path.yml b/changelogs/unreleased/migrate-artifacts-to-a-new-path.yml new file mode 100644 index 00000000000..bd022a3a91b --- /dev/null +++ b/changelogs/unreleased/migrate-artifacts-to-a-new-path.yml @@ -0,0 +1,4 @@ +--- +title: Migrate artifacts to a new path +merge_request: +author: diff --git a/changelogs/unreleased/mk-fix-git-over-http-rejections.yml b/changelogs/unreleased/mk-fix-git-over-http-rejections.yml new file mode 100644 index 00000000000..e75740e913f --- /dev/null +++ b/changelogs/unreleased/mk-fix-git-over-http-rejections.yml @@ -0,0 +1,4 @@ +--- +title: Fix Git-over-HTTP error statuses and improve error messages +merge_request: 11398 +author: diff --git a/changelogs/unreleased/mrchrisw-catch-openssl.yml b/changelogs/unreleased/mrchrisw-catch-openssl.yml new file mode 100644 index 00000000000..a8b433fb0cd --- /dev/null +++ b/changelogs/unreleased/mrchrisw-catch-openssl.yml @@ -0,0 +1,4 @@ +--- +title: Rescue OpenSSL::SSL::SSLError in JiraService & IssueTrackerService +merge_request: +author: diff --git a/changelogs/unreleased/omega-submodules.yml b/changelogs/unreleased/omega-submodules.yml new file mode 100644 index 00000000000..1488eb72174 --- /dev/null +++ b/changelogs/unreleased/omega-submodules.yml @@ -0,0 +1,4 @@ +--- +title: 'Repository browser: handle in-repository submodule urls' +merge_request: +author: David Turner diff --git a/changelogs/unreleased/prevent-project-transfer.yml b/changelogs/unreleased/prevent-project-transfer.yml new file mode 100644 index 00000000000..a5c74676aab --- /dev/null +++ b/changelogs/unreleased/prevent-project-transfer.yml @@ -0,0 +1,4 @@ +--- +title: Prevent project transfers if a new group is not selected +merge_request: +author: diff --git a/changelogs/unreleased/projects-api-import-status.yml b/changelogs/unreleased/projects-api-import-status.yml new file mode 100644 index 00000000000..06603c0adec --- /dev/null +++ b/changelogs/unreleased/projects-api-import-status.yml @@ -0,0 +1,4 @@ +--- +title: Expose import_status in Projects API +merge_request: 11851 +author: Robin Bobbitt diff --git a/changelogs/unreleased/protected-branches-no-one-merge.yml b/changelogs/unreleased/protected-branches-no-one-merge.yml new file mode 100644 index 00000000000..52d93793f3d --- /dev/null +++ b/changelogs/unreleased/protected-branches-no-one-merge.yml @@ -0,0 +1,4 @@ +--- +title: Allow 'no one' as an option for allowed to merge on a procted branch +merge_request: +author: diff --git a/changelogs/unreleased/remove-old-isobject.yml b/changelogs/unreleased/remove-old-isobject.yml new file mode 100644 index 00000000000..67b18642253 --- /dev/null +++ b/changelogs/unreleased/remove-old-isobject.yml @@ -0,0 +1,4 @@ +--- +title: Remove unused code and uses underscore +merge_request: +author: diff --git a/changelogs/unreleased/rename-builds-controller.yml b/changelogs/unreleased/rename-builds-controller.yml new file mode 100644 index 00000000000..7f6872ccf95 --- /dev/null +++ b/changelogs/unreleased/rename-builds-controller.yml @@ -0,0 +1,4 @@ +--- +title: Change /builds in the URL to /-/jobs. Backward URLs were also added +merge_request: 11407 +author: diff --git a/changelogs/unreleased/search-restrict-projects-to-group.yml b/changelogs/unreleased/search-restrict-projects-to-group.yml new file mode 100644 index 00000000000..ac134bc5bce --- /dev/null +++ b/changelogs/unreleased/search-restrict-projects-to-group.yml @@ -0,0 +1,4 @@ +--- +title: Restricts search projects dropdown to group projects when group is selected +merge_request: +author: diff --git a/changelogs/unreleased/sh-fix-container-registry-s3-redirects.yml b/changelogs/unreleased/sh-fix-container-registry-s3-redirects.yml new file mode 100644 index 00000000000..1e783811b66 --- /dev/null +++ b/changelogs/unreleased/sh-fix-container-registry-s3-redirects.yml @@ -0,0 +1,4 @@ +--- +title: Properly handle container registry redirects to fix metadata stored on a S3 backend +merge_request: +author: diff --git a/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml b/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml new file mode 100644 index 00000000000..161bce45601 --- /dev/null +++ b/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml @@ -0,0 +1,4 @@ +--- +title: Fix LFS timeouts when trying to save large files +merge_request: +author: diff --git a/changelogs/unreleased/sh-fix-submodules-trailing-spaces.yml b/changelogs/unreleased/sh-fix-submodules-trailing-spaces.yml new file mode 100644 index 00000000000..d633995d467 --- /dev/null +++ b/changelogs/unreleased/sh-fix-submodules-trailing-spaces.yml @@ -0,0 +1,4 @@ +--- +title: Strip trailing whitespaces in submodule URLs +merge_request: +author: diff --git a/changelogs/unreleased/sync-email-from-omniauth.yml b/changelogs/unreleased/sync-email-from-omniauth.yml new file mode 100644 index 00000000000..ed14a95a5f1 --- /dev/null +++ b/changelogs/unreleased/sync-email-from-omniauth.yml @@ -0,0 +1,4 @@ +--- +title: Sync email address from specified omniauth provider +merge_request: 11268 +author: Robin Bobbitt diff --git a/changelogs/unreleased/task-list-2.yml b/changelogs/unreleased/task-list-2.yml new file mode 100644 index 00000000000..cbae8178081 --- /dev/null +++ b/changelogs/unreleased/task-list-2.yml @@ -0,0 +1,4 @@ +--- +title: Update task_list to version 2.0.0 +merge_request: 11525 +author: Jared Deckard <jared.deckard@gmail.com> diff --git a/changelogs/unreleased/tc-cache-trackable-attributes.yml b/changelogs/unreleased/tc-cache-trackable-attributes.yml new file mode 100644 index 00000000000..4a2cf50893a --- /dev/null +++ b/changelogs/unreleased/tc-cache-trackable-attributes.yml @@ -0,0 +1,4 @@ +--- +title: "Limit User's trackable attributes, like `current_sign_in_at`, to update at most once/hour" +merge_request: 11053 +author: diff --git a/changelogs/unreleased/tc-clean-pending-delete-projects.yml b/changelogs/unreleased/tc-clean-pending-delete-projects.yml new file mode 100644 index 00000000000..31b43999c31 --- /dev/null +++ b/changelogs/unreleased/tc-clean-pending-delete-projects.yml @@ -0,0 +1,4 @@ +--- +title: Add post-deploy migration to clean up projects in `pending_delete` state +merge_request: 11044 +author: diff --git a/changelogs/unreleased/tc-improve-project-api-perf.yml b/changelogs/unreleased/tc-improve-project-api-perf.yml new file mode 100644 index 00000000000..7e88466c058 --- /dev/null +++ b/changelogs/unreleased/tc-improve-project-api-perf.yml @@ -0,0 +1,4 @@ +--- +title: Improve performance of ProjectFinder used in /projects API endpoint +merge_request: 11666 +author: diff --git a/changelogs/unreleased/up-arrow-focus-discussion-comment.yml b/changelogs/unreleased/up-arrow-focus-discussion-comment.yml new file mode 100644 index 00000000000..5457dab6d3d --- /dev/null +++ b/changelogs/unreleased/up-arrow-focus-discussion-comment.yml @@ -0,0 +1,4 @@ +--- +title: Fix up arrow not editing last discussion comment +merge_request: +author: diff --git a/changelogs/unreleased/update-admin-health-page.yml b/changelogs/unreleased/update-admin-health-page.yml new file mode 100644 index 00000000000..51aa6682b49 --- /dev/null +++ b/changelogs/unreleased/update-admin-health-page.yml @@ -0,0 +1,5 @@ +--- +title: Added application readiness endpoints to the monitoring health check admin + view +merge_request: +author: diff --git a/changelogs/unreleased/use_relative_path_for_project_avatars.yml b/changelogs/unreleased/use_relative_path_for_project_avatars.yml new file mode 100644 index 00000000000..e3d0c0e1187 --- /dev/null +++ b/changelogs/unreleased/use_relative_path_for_project_avatars.yml @@ -0,0 +1,4 @@ +--- +title: Use relative paths for group/project/user avatars +merge_request: 11001 +author: blackst0ne diff --git a/changelogs/unreleased/wait-for-ajax-handling-all-js-requests.yml b/changelogs/unreleased/wait-for-ajax-handling-all-js-requests.yml new file mode 100644 index 00000000000..14aebe792c2 --- /dev/null +++ b/changelogs/unreleased/wait-for-ajax-handling-all-js-requests.yml @@ -0,0 +1,4 @@ +--- +title: Use wait_for_requests for both ajax and Vue requests +merge_request: +author: diff --git a/changelogs/unreleased/winh-current-user-filter.yml b/changelogs/unreleased/winh-current-user-filter.yml new file mode 100644 index 00000000000..e5409827b31 --- /dev/null +++ b/changelogs/unreleased/winh-current-user-filter.yml @@ -0,0 +1,4 @@ +--- +title: Show current user immediately in issuable filters +merge_request: 11630 +author: diff --git a/changelogs/unreleased/winh-pipeline-author-link.yml b/changelogs/unreleased/winh-pipeline-author-link.yml new file mode 100644 index 00000000000..1b903d1e357 --- /dev/null +++ b/changelogs/unreleased/winh-pipeline-author-link.yml @@ -0,0 +1,4 @@ +--- +title: Link to commit author user page from pipelines +merge_request: 11100 +author: diff --git a/changelogs/unreleased/winh-styled-people-search-bar.yml b/changelogs/unreleased/winh-styled-people-search-bar.yml new file mode 100644 index 00000000000..a088af37d8d --- /dev/null +++ b/changelogs/unreleased/winh-styled-people-search-bar.yml @@ -0,0 +1,4 @@ +--- +title: Style people in issuable search bar +merge_request: 11402 +author: diff --git a/changelogs/unreleased/zj-clean-up-ci-variables-table.yml b/changelogs/unreleased/zj-clean-up-ci-variables-table.yml new file mode 100644 index 00000000000..ea2db40d590 --- /dev/null +++ b/changelogs/unreleased/zj-clean-up-ci-variables-table.yml @@ -0,0 +1,4 @@ +--- +title: Cleanup ci_variables schema and table +merge_request: +author: diff --git a/changelogs/unreleased/zj-drop-fk-if-exists.yml b/changelogs/unreleased/zj-drop-fk-if-exists.yml new file mode 100644 index 00000000000..237ba936de9 --- /dev/null +++ b/changelogs/unreleased/zj-drop-fk-if-exists.yml @@ -0,0 +1,4 @@ +--- +title: Remove foreigh key on ci_trigger_schedules only if it exists +merge_request: +author: diff --git a/changelogs/unreleased/zj-job-view-goes-real-time.yml b/changelogs/unreleased/zj-job-view-goes-real-time.yml new file mode 100644 index 00000000000..376c9dfa65f --- /dev/null +++ b/changelogs/unreleased/zj-job-view-goes-real-time.yml @@ -0,0 +1,4 @@ +--- +title: Job details page update real time +merge_request: 11651 +author: diff --git a/changelogs/unreleased/zj-pipeline-schedule-owner.yml b/changelogs/unreleased/zj-pipeline-schedule-owner.yml new file mode 100644 index 00000000000..be704e173ab --- /dev/null +++ b/changelogs/unreleased/zj-pipeline-schedule-owner.yml @@ -0,0 +1,4 @@ +--- +title: Add foreign key for pipeline schedule owner +merge_request: 11233 +author: diff --git a/changelogs/unreleased/zj-read-registry-pat.yml b/changelogs/unreleased/zj-read-registry-pat.yml new file mode 100644 index 00000000000..d36159bbdf5 --- /dev/null +++ b/changelogs/unreleased/zj-read-registry-pat.yml @@ -0,0 +1,4 @@ +--- +title: Allow pulling of container images using personal access tokens +merge_request: 11845 +author: diff --git a/changelogs/unreleased/zj-realtime-env-list.yml b/changelogs/unreleased/zj-realtime-env-list.yml new file mode 100644 index 00000000000..6460d17edc9 --- /dev/null +++ b/changelogs/unreleased/zj-realtime-env-list.yml @@ -0,0 +1,4 @@ +--- +title: Make environment table realtime +merge_request: 11333 +author: diff --git a/changelogs/unreleased/zj-sort-env-folders.yml b/changelogs/unreleased/zj-sort-env-folders.yml new file mode 100644 index 00000000000..b3ca97aef94 --- /dev/null +++ b/changelogs/unreleased/zj-sort-env-folders.yml @@ -0,0 +1,4 @@ +--- +title: Sort folder for environments +merge_request: +author: diff --git a/config.ru b/config.ru index 065ce59932f..89aba462f19 100644 --- a/config.ru +++ b/config.ru @@ -15,6 +15,9 @@ if defined?(Unicorn) end end +# set default directory for multiproces metrics gathering +ENV['prometheus_multiproc_dir'] ||= 'tmp/prometheus_multiproc_dir' + require ::File.expand_path('../config/environment', __FILE__) map ENV['RAILS_RELATIVE_URL_ROOT'] || "/" do diff --git a/config/application.rb b/config/application.rb index 95ba6774916..b0533759252 100644 --- a/config/application.rb +++ b/config/application.rb @@ -65,6 +65,7 @@ module Gitlab hook import_url incoming_email_token + rss_token key otp_attempt password diff --git a/config/environments/production.rb b/config/environments/production.rb index a9d8ac4b6d4..82a19085b1d 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -16,7 +16,7 @@ Rails.application.configure do # config.assets.css_compressor = :sass # Don't fallback to assets pipeline if a precompiled asset is missed - config.assets.compile = true + config.assets.compile = false # Generate digests for assets URLs config.assets.digest = true diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 14d99c243fc..0b33783869b 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -169,7 +169,7 @@ production: &base ## Gravatar ## For Libravatar see: http://doc.gitlab.com/ce/customization/libravatar.html gravatar: - # gravatar urls: possible placeholders: %{hash} %{size} %{email} + # gravatar urls: possible placeholders: %{hash} %{size} %{email} %{username} # plain_url: "http://..." # default: http://www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon # ssl_url: "https://..." # default: https://secure.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon @@ -182,7 +182,7 @@ production: &base cron: "0 * * * *" # Execute scheduled triggers pipeline_schedule_worker: - cron: "0 */12 * * *" + cron: "19 * * * *" # Remove expired build artifacts expire_build_artifacts_worker: cron: "50 * * * *" @@ -337,6 +337,10 @@ production: &base # showing GitLab's sign-in page (default: show the GitLab sign-in page) # auto_sign_in_with_provider: saml + # Sync user's email address from the specified Omniauth provider every time the user logs + # in (default: nil). And consequently make this field read-only. + # sync_email_from_provider: cas3 + # CAUTION! # This allows users to login without having a user account first. Define the allowed providers # using an array, e.g. ["saml", "twitter"], or as true/false to allow all providers or none. @@ -449,7 +453,7 @@ production: &base # This setting controls whether GitLab uses Gitaly (new component # introduced in 9.0). Eventually Gitaly use will become mandatory and # this option will disappear. - enabled: false + enabled: true # # 4. Advanced settings diff --git a/config/initializers/acts_as_taggable.rb b/config/initializers/0_acts_as_taggable.rb index c564c0cab11..54e9fcc31db 100644 --- a/config/initializers/acts_as_taggable.rb +++ b/config/initializers/0_acts_as_taggable.rb @@ -3,3 +3,7 @@ ActsAsTaggableOn.strict_case_match = true # tags_counter enables caching count of tags which results in an update whenever a tag is added or removed # since the count is not used anywhere its better performance wise to disable this cache ActsAsTaggableOn.tags_counter = false + +# validate that counter cache is disabled +raise "Counter cache is not disabled" if + ActsAsTaggableOn::Tagging.reflections["tag"].options[:counter_cache] diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 5a90830b5b3..8ddf8e4d2e4 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -156,6 +156,7 @@ Settings.omniauth['external_providers'] = [] if Settings.omniauth['external_prov Settings.omniauth['block_auto_created_users'] = true if Settings.omniauth['block_auto_created_users'].nil? Settings.omniauth['auto_link_ldap_user'] = false if Settings.omniauth['auto_link_ldap_user'].nil? Settings.omniauth['auto_link_saml_user'] = false if Settings.omniauth['auto_link_saml_user'].nil? +Settings.omniauth['sync_email_from_provider'] ||= nil Settings.omniauth['providers'] ||= [] Settings.omniauth['cas3'] ||= Settingslogic.new({}) @@ -368,11 +369,14 @@ Settings.cron_jobs['gitlab_usage_ping_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_random_weekly_time) Settings.cron_jobs['gitlab_usage_ping_worker']['job_class'] = 'GitlabUsagePingWorker' -# Every day at 00:30 Settings.cron_jobs['schedule_update_user_activity_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['schedule_update_user_activity_worker']['cron'] ||= '30 0 * * *' Settings.cron_jobs['schedule_update_user_activity_worker']['job_class'] = 'ScheduleUpdateUserActivityWorker' +Settings.cron_jobs['remove_old_web_hook_logs_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['remove_old_web_hook_logs_worker']['cron'] ||= '40 0 * * *' +Settings.cron_jobs['remove_old_web_hook_logs_worker']['job_class'] = 'RemoveOldWebHookLogsWorker' + # # GitLab Shell # @@ -479,7 +483,7 @@ Settings.rack_attack.git_basic_auth['bantime'] ||= 1.hour # Gitaly # Settings['gitaly'] ||= Settingslogic.new({}) -Settings.gitaly['enabled'] ||= false +Settings.gitaly['enabled'] = true if Settings.gitaly['enabled'].nil? # # Webpack settings diff --git a/config/initializers/ar_monkey_patch.rb b/config/initializers/active_record_locking.rb index 6979f4641b0..9266ff0f615 100644 --- a/config/initializers/ar_monkey_patch.rb +++ b/config/initializers/active_record_locking.rb @@ -33,7 +33,7 @@ module ActiveRecord affected_rows = relation.where( self.class.primary_key => id, - lock_col => previous_lock_value, + lock_col => previous_lock_value ).update_all( attributes_for_update(attribute_names).map do |name| [name, _read_attribute(name)] diff --git a/config/initializers/active_record_preloader.rb b/config/initializers/active_record_preloader.rb new file mode 100644 index 00000000000..3b16014f302 --- /dev/null +++ b/config/initializers/active_record_preloader.rb @@ -0,0 +1,15 @@ +module ActiveRecord + module Associations + class Preloader + module NoCommitPreloader + def preloader_for(reflection, owners, rhs_klass) + return NullPreloader if rhs_klass == ::Commit + + super + end + end + + prepend NoCommitPreloader + end + end +end diff --git a/config/initializers/ar_speed_up_migration_checking.rb b/config/initializers/ar_speed_up_migration_checking.rb index 1fe5defc01d..aae774daa35 100644 --- a/config/initializers/ar_speed_up_migration_checking.rb +++ b/config/initializers/ar_speed_up_migration_checking.rb @@ -10,7 +10,7 @@ if Rails.env.test? # it reads + parses `db/migrate/*` each time. Memoizing it can save 0.5 # seconds per spec. def migrations(paths) - @migrations ||= migrations_unmemoized(paths) + (@migrations ||= migrations_unmemoized(paths)).dup end end end diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb index 700ca25b884..4ff9019c43c 100644 --- a/config/initializers/doorkeeper_openid_connect.rb +++ b/config/initializers/doorkeeper_openid_connect.rb @@ -30,7 +30,7 @@ Doorkeeper::OpenidConnect.configure do o.claim(:email_verified) { |user| true if user.public_email? } o.claim(:website) { |user| user.full_website_url if user.website_url? } o.claim(:profile) { |user| Rails.application.routes.url_helpers.user_url user } - o.claim(:picture) { |user| user.avatar_url } + o.claim(:picture) { |user| user.avatar_url(only_path: false) } end end end diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb index a69fe0c902e..eb589ecdb52 100644 --- a/config/initializers/fast_gettext.rb +++ b/config/initializers/fast_gettext.rb @@ -1,5 +1,6 @@ FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po FastGettext.default_text_domain = 'gitlab' FastGettext.default_available_locales = Gitlab::I18n.available_locales +FastGettext.default_locale = :en I18n.available_locales = Gitlab::I18n.available_locales diff --git a/config/initializers/forbid_sidekiq_in_transactions.rb b/config/initializers/forbid_sidekiq_in_transactions.rb new file mode 100644 index 00000000000..a78711fe599 --- /dev/null +++ b/config/initializers/forbid_sidekiq_in_transactions.rb @@ -0,0 +1,49 @@ +module Sidekiq + module Worker + mattr_accessor :skip_transaction_check + self.skip_transaction_check = false + + def self.skipping_transaction_check(&block) + skip_transaction_check = self.skip_transaction_check + self.skip_transaction_check = true + yield + ensure + self.skip_transaction_check = skip_transaction_check + end + + module ClassMethods + module NoSchedulingFromTransactions + NESTING = ::Rails.env.test? ? 1 : 0 + + %i(perform_async perform_at perform_in).each do |name| + define_method(name) do |*args| + return super(*args) if Sidekiq::Worker.skip_transaction_check + return super(*args) unless ActiveRecord::Base.connection.open_transactions > NESTING + + raise <<-MSG.strip_heredoc + `#{self}.#{name}` cannot be called inside a transaction as this can lead to + race conditions when the worker runs before the transaction is committed and + tries to access a model that has not been saved yet. + + Use an `after_commit` hook, or include `AfterCommitQueue` and use a `run_after_commit` block instead. + MSG + end + end + end + + prepend NoSchedulingFromTransactions + end + end +end + +module ActiveRecord + class Base + module SkipTransactionCheckAfterCommit + def committed!(*) + Sidekiq::Worker.skipping_transaction_check { super } + end + end + + prepend SkipTransactionCheckAfterCommit + end +end diff --git a/config/initializers/hamlit.rb b/config/initializers/hamlit.rb index 7b545d8c06c..51dbffeda05 100644 --- a/config/initializers/hamlit.rb +++ b/config/initializers/hamlit.rb @@ -3,7 +3,7 @@ module Hamlit def call(template) Engine.new( generator: Temple::Generators::RailsOutputBuffer, - attr_quote: '"', + attr_quote: '"' ).call(template.source) end end @@ -11,7 +11,7 @@ end ActionView::Template.register_template_handler( :haml, - Hamlit::TemplateHandler.new, + Hamlit::TemplateHandler.new ) Hamlit::Filters.remove_filter('coffee') diff --git a/config/initializers/relative_naming_ci_namespace.rb b/config/initializers/relative_naming_ci_namespace.rb index 59abe1b9b91..03ac55be0b6 100644 --- a/config/initializers/relative_naming_ci_namespace.rb +++ b/config/initializers/relative_naming_ci_namespace.rb @@ -4,7 +4,7 @@ # - [project.namespace, project, build] # # instead of: -# - namespace_project_build_path(project.namespace, project, build) +# - namespace_project_job_path(project.namespace, project, build) # # Without that, Ci:: namespace is used for resolving routes: # - namespace_project_ci_build_path(project.namespace, project, build) diff --git a/config/initializers/rspec_profiling.rb b/config/initializers/rspec_profiling.rb index a7efd74f09e..16b9d5b15e5 100644 --- a/config/initializers/rspec_profiling.rb +++ b/config/initializers/rspec_profiling.rb @@ -32,7 +32,7 @@ end if Rails.env.test? RspecProfiling.configure do |config| - if ENV['RSPEC_PROFILING_POSTGRES_URL'] + if ENV['RSPEC_PROFILING_POSTGRES_URL'].present? RspecProfiling::Collectors::PSQL.prepend(RspecProfilingExt::PSQL) config.collector = RspecProfiling::Collectors::PSQL end diff --git a/config/initializers/server_uptime.rb b/config/initializers/server_uptime.rb new file mode 100644 index 00000000000..46bf242e143 --- /dev/null +++ b/config/initializers/server_uptime.rb @@ -0,0 +1 @@ +Rails.application.config.booted_at = Time.now diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 70be2617cab..8919f7640fe 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -10,6 +10,12 @@ rescue Settings.gitlab['session_expire_delay'] ||= 10080 end +cookie_key = if Rails.env.development? + "_gitlab_session_#{Digest::SHA256.hexdigest(Rails.root.to_s)}" + else + "_gitlab_session" + end + if Rails.env.test? Gitlab::Application.config.session_store :cookie_store, key: "_gitlab_session" else @@ -19,7 +25,7 @@ else Gitlab::Application.config.session_store( :redis_store, # Using the cookie_store would enable session replay attacks. servers: redis_config, - key: '_gitlab_session', + key: cookie_key, secure: Gitlab.config.gitlab.https, httponly: true, expires_in: Settings.gitlab['session_expire_delay'] * 60, diff --git a/config/initializers/static_files.rb b/config/initializers/static_files.rb index 74aba6c5d06..9ed96ddb0b4 100644 --- a/config/initializers/static_files.rb +++ b/config/initializers/static_files.rb @@ -23,21 +23,21 @@ if app.config.serve_static_files host: dev_server.host, port: dev_server.port, manifest_host: dev_server.host, - manifest_port: dev_server.port, + manifest_port: dev_server.port } if Rails.env.development? settings.merge!( host: Gitlab.config.gitlab.host, port: Gitlab.config.gitlab.port, - https: Gitlab.config.gitlab.https, + https: Gitlab.config.gitlab.https ) app.config.middleware.insert_before( Gitlab::Middleware::Static, Gitlab::Middleware::WebpackProxy, proxy_path: app.config.webpack.public_path, proxy_host: dev_server.host, - proxy_port: dev_server.port, + proxy_port: dev_server.port ) end diff --git a/config/karma.config.js b/config/karma.config.js index eb082dd28bf..40c58e7771d 100644 --- a/config/karma.config.js +++ b/config/karma.config.js @@ -13,6 +13,8 @@ if (webpackConfig.plugins) { }); } +webpackConfig.devtool = 'cheap-inline-source-map'; + // Karma configuration module.exports = function(config) { var progressReporter = process.env.CI ? 'mocha' : 'progress'; diff --git a/config/locales/en.yml b/config/locales/en.yml index 12a59be79f0..9d47425950a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -13,3 +13,39 @@ en: pagination: previous: "Prev" next: "Next" + datetime: + time_ago_in_words: + half_a_minute: "half a minute ago" + less_than_x_seconds: + one: "less than 1 second ago" + other: "less than %{count} seconds ago" + x_seconds: + one: "1 second ago" + other: "%{count} seconds ago" + less_than_x_minutes: + one: "less than a minute ago" + other: "less than %{count} minutes ago" + x_minutes: + one: "1 minute ago" + other: "%{count} minutes ago" + about_x_hours: + one: "about 1 hour ago" + other: "about %{count} hours ago" + x_days: + one: "1 day ago" + other: "%{count} days ago" + about_x_months: + one: "about 1 month ago" + other: "about %{count} months ago" + x_months: + one: "1 month ago" + other: "%{count} months ago" + about_x_years: + one: "about 1 year ago" + other: "about %{count} years ago" + over_x_years: + one: "over 1 year ago" + other: "over %{count} years ago" + almost_x_years: + one: "almost 1 year ago" + other: "almost %{count} years ago" diff --git a/config/locales/es.yml b/config/locales/es.yml index 87e79beee74..0f9dc39535d 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -61,6 +61,41 @@ es: - :month - :year datetime: + time_ago_in_words: + half_a_minute: "hace medio minuto" + less_than_x_seconds: + one: "hace menos de 1 segundo" + other: "hace menos de %{count} segundos" + x_seconds: + one: "hace 1 segundo" + other: "hace %{count} segundos" + less_than_x_minutes: + one: "hace menos de un minuto" + other: "hace menos de %{count} minutos" + x_minutes: + one: "hace 1 minuto" + other: "hace %{count} minutos" + about_x_hours: + one: "hace alrededor de 1 hora" + other: "hace alrededor de %{count} horas" + x_days: + one: "hace un día" + other: "hace %{count} días" + about_x_months: + one: "hace alrededor de 1 mes" + other: "hace alrededor de %{count} meses" + x_months: + one: "hace 1 mes" + other: "hace %{count} meses" + about_x_years: + one: "hace alrededor de 1 año" + other: "hace alrededor de %{count} años" + over_x_years: + one: "hace más de 1 año" + other: "hace %{count} años" + almost_x_years: + one: "hace casi 1 año" + other: "hace casi %{count} años" distance_in_words: about_x_hours: one: alrededor de 1 hora diff --git a/config/routes.rb b/config/routes.rb index 2584981bb04..d909be38b42 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,5 @@ require 'sidekiq/web' require 'sidekiq/cron/web' -require 'constraints/group_url_constrainer' Rails.application.routes.draw do concern :access_requestable do @@ -39,10 +38,10 @@ Rails.application.routes.draw do # Health check get 'health_check(/:checks)' => 'health_check#index', as: :health_check - scope path: '-', controller: 'health' do - get :liveness - get :readiness - get :metrics + scope path: '-' do + get 'liveness' => 'health#liveness' + get 'readiness' => 'health#readiness' + resources :metrics, only: [:index] end # Koding route @@ -85,20 +84,6 @@ Rails.application.routes.draw do root to: "root#index" - # Since group show page is wildcard routing - # we want all other routing to be checked before matching this one - constraints(GroupUrlConstrainer.new) do - scope(path: '*id', - as: :group, - constraints: { id: Gitlab::Regex.namespace_route_regex, format: /(html|json|atom)/ }, - controller: :groups) do - get '/', action: :show - patch '/', action: :update - put '/', action: :update - delete '/', action: :destroy - end - end - draw :test if Rails.env.test? get '*unmatched_route', to: 'application#route_not_found' diff --git a/config/routes/admin.rb b/config/routes/admin.rb index b1b6ef33a47..be023e2654d 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -36,7 +36,7 @@ namespace :admin do scope(path: 'groups/*id', controller: :groups, - constraints: { id: Gitlab::Regex.namespace_route_regex, format: /(html|json|atom)/ }) do + constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ }) do scope(as: :group) do put :members_update @@ -48,12 +48,18 @@ namespace :admin do end end - resources :deploy_keys, only: [:index, :new, :create, :destroy] + resources :deploy_keys, only: [:index, :new, :create, :edit, :update, :destroy] resources :hooks, only: [:index, :create, :edit, :update, :destroy] do member do get :test end + + resources :hook_logs, only: [:show] do + member do + get :retry + end + end end resources :broadcast_messages, only: [:index, :edit, :create, :update, :destroy] do @@ -66,14 +72,20 @@ namespace :admin do resource :system_info, controller: 'system_info', only: [:show] resources :requests_profiles, only: [:index, :show], param: :name, constraints: { name: /.+\.html/ } + get 'conversational_development_index' => 'conversational_development_index#show' + resources :projects, only: [:index] scope(path: 'projects/*namespace_id', as: :namespace, +<<<<<<< HEAD constraints: { namespace_id: Gitlab::Regex.namespace_route_regex }) do +======= + constraints: { namespace_id: Gitlab::PathRegex.full_namespace_route_regex }) do +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 resources(:projects, path: '/', - constraints: { id: Gitlab::Regex.project_route_regex }, + constraints: { id: Gitlab::PathRegex.project_route_regex }, only: [:show]) do member do @@ -112,7 +124,7 @@ namespace :admin do resources :cohorts, only: :index - resources :builds, only: :index do + resources :jobs, only: :index do collection do post :cancel_all end diff --git a/config/routes/git_http.rb b/config/routes/git_http.rb index cdf658c3e4a..adfec18b638 100644 --- a/config/routes/git_http.rb +++ b/config/routes/git_http.rb @@ -1,7 +1,12 @@ scope(path: '*namespace_id/:project_id', format: nil, +<<<<<<< HEAD constraints: { namespace_id: Gitlab::Regex.namespace_route_regex }) do scope(constraints: { project_id: Gitlab::Regex.project_git_route_regex }, module: :projects) do +======= + constraints: { namespace_id: Gitlab::PathRegex.full_namespace_route_regex }) do + scope(constraints: { project_id: Gitlab::PathRegex.project_git_route_regex }, module: :projects) do +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 # Git HTTP clients ('git clone' etc.) scope(controller: :git_http) do get '/info/refs', action: :info_refs @@ -28,7 +33,7 @@ scope(path: '*namespace_id/:project_id', end # Redirect /group/project/info/refs to /group/project.git/info/refs - scope(constraints: { project_id: Gitlab::Regex.project_route_regex }) do + scope(constraints: { project_id: Gitlab::PathRegex.project_route_regex }) do # Allow /info/refs, /info/refs?service=git-upload-pack, and # /info/refs?service=git-receive-pack, but nothing else. # diff --git a/config/routes/group.rb b/config/routes/group.rb index 7b29e0e807c..11cdff55ed8 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -1,9 +1,11 @@ +require 'constraints/group_url_constrainer' + resources :groups, only: [:index, :new, :create] scope(path: 'groups/*group_id', module: :groups, as: :group, - constraints: { group_id: Gitlab::Regex.namespace_route_regex }) do + constraints: { group_id: Gitlab::PathRegex.full_namespace_route_regex }) do resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do post :resend_invite, on: :member delete :leave, on: :collection @@ -25,7 +27,7 @@ end scope(path: 'groups/*id', controller: :groups, - constraints: { id: Gitlab::Regex.namespace_route_regex, format: /(html|json|atom)/ }) do + constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ }) do get :edit, as: :edit_group get :issues, as: :issues_group get :merge_requests, as: :merge_requests_group @@ -34,3 +36,15 @@ scope(path: 'groups/*id', get :subgroups, as: :subgroups_group get '/', action: :show, as: :group_canonical end + +constraints(GroupUrlConstrainer.new) do + scope(path: '*id', + as: :group, + constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ }, + controller: :groups) do + get '/', action: :show + patch '/', action: :update + put '/', action: :update + delete '/', action: :destroy + end +end diff --git a/config/routes/profile.rb b/config/routes/profile.rb index 07c341999ea..3dc890e5785 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -5,6 +5,7 @@ resource :profile, only: [:show, :update] do put :reset_private_token put :reset_incoming_email_token + put :reset_rss_token put :update_username end diff --git a/config/routes/project.rb b/config/routes/project.rb index 25c85f8e5c7..838f002ccb3 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -1,4 +1,5 @@ require 'constraints/project_url_constrainer' +require 'gitlab/routes/legacy_builds' resources :projects, only: [:index, :new, :create] @@ -13,16 +14,24 @@ constraints(ProjectUrlConstrainer.new) do # Otherwise, Rails will overwrite the constraint with `/.+?/`, # which breaks some of our wildcard routes like `/blob/*id` # and `/tree/*id` that depend on the negative lookahead inside +<<<<<<< HEAD # `Gitlab::Regex.namespace_route_regex`, which helps the router +======= + # `Gitlab::PathRegex.full_namespace_route_regex`, which helps the router +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 # determine whether a certain path segment is part of `*namespace_id`, # `:project_id`, or `*id`. # # See https://github.com/rails/rails/blob/v4.2.8/actionpack/lib/action_dispatch/routing/mapper.rb#L155 scope(path: '*namespace_id', as: :namespace, +<<<<<<< HEAD namespace_id: Gitlab::Regex.namespace_route_regex) do +======= + namespace_id: Gitlab::PathRegex.full_namespace_route_regex) do +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 scope(path: ':project_id', - constraints: { project_id: Gitlab::Regex.project_route_regex }, + constraints: { project_id: Gitlab::PathRegex.project_route_regex }, module: :projects, as: :project) do @@ -66,13 +75,13 @@ constraints(ProjectUrlConstrainer.new) do resources :services, constraints: { id: /[^\/]+/ }, only: [:index, :edit, :update] do member do - get :test + put :test end end resource :mattermost, only: [:new, :create] - resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create] do + resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create, :edit, :update] do member do put :enable put :disable @@ -180,42 +189,52 @@ constraints(ProjectUrlConstrainer.new) do end end - 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 + scope '-' do + resources :jobs, 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 - end - member do - get :status - post :cancel - post :retry - post :play - post :erase - get :trace, defaults: { format: 'json' } - get :raw - end + member do + get :status + post :cancel + post :retry + post :play + post :erase + get :trace, defaults: { format: 'json' } + get :raw + end - resource :artifacts, only: [] do - get :download - get :browse, path: 'browse(/*path)', format: false - get :file, path: 'file/*path', format: false - get :raw, path: 'raw/*path', format: false - post :keep + resource :artifacts, only: [] do + get :download + get :browse, path: 'browse(/*path)', format: false + get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false + post :keep + end end end + Gitlab::Routes::LegacyBuilds.new(self).draw + resources :hooks, only: [:index, :create, :edit, :update, :destroy], constraints: { id: /\d+/ } do member do get :test end + + resources :hook_logs, only: [:show] do + member do + get :retry + end + end end resources :container_registry, only: [:index, :destroy], @@ -258,7 +277,7 @@ constraints(ProjectUrlConstrainer.new) do get :referenced_merge_requests get :related_branches get :can_create_branch - get :rendered_title + get :realtime_changes post :create_merge_request end collection do @@ -329,7 +348,7 @@ constraints(ProjectUrlConstrainer.new) do resources :runner_projects, only: [:create, :destroy] resources :badges, only: [:index] do collection do - scope '*ref', constraints: { ref: Gitlab::Regex.git_reference_regex } do + scope '*ref', constraints: { ref: Gitlab::PathRegex.git_reference_regex } do constraints format: /svg/ do get :build get :coverage @@ -352,7 +371,7 @@ constraints(ProjectUrlConstrainer.new) do resources(:projects, path: '/', - constraints: { id: Gitlab::Regex.project_route_regex }, + constraints: { id: Gitlab::PathRegex.project_route_regex }, only: [:edit, :show, :update, :destroy]) do member do put :transfer diff --git a/config/routes/repository.rb b/config/routes/repository.rb index 5cf37a06e97..11911636fa7 100644 --- a/config/routes/repository.rb +++ b/config/routes/repository.rb @@ -2,7 +2,7 @@ resource :repository, only: [:create] do member do - get 'archive', constraints: { format: Gitlab::Regex.archive_formats_regex } + get 'archive', constraints: { format: Gitlab::PathRegex.archive_formats_regex } end end @@ -24,7 +24,7 @@ scope format: false do member do # tree viewer logs - get 'logs_tree', constraints: { id: Gitlab::Regex.git_reference_regex } + get 'logs_tree', constraints: { id: Gitlab::PathRegex.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', action: :logs_tree, as: :logs_file, format: false, constraints: { @@ -34,7 +34,7 @@ scope format: false do end end - scope constraints: { id: Gitlab::Regex.git_reference_regex } do + scope constraints: { id: Gitlab::PathRegex.git_reference_regex } do resources :network, only: [:show] resources :graphs, only: [:show] do diff --git a/config/routes/snippets.rb b/config/routes/snippets.rb index dae83734fe6..0a4ebac3ca3 100644 --- a/config/routes/snippets.rb +++ b/config/routes/snippets.rb @@ -2,6 +2,9 @@ resources :snippets, concerns: :awardable do member do get :raw post :mark_as_spam + end + + collection do post :preview_markdown end diff --git a/config/routes/uploads.rb b/config/routes/uploads.rb index ed3fd21d04f..a49e244af1a 100644 --- a/config/routes/uploads.rb +++ b/config/routes/uploads.rb @@ -9,6 +9,11 @@ scope path: :uploads do to: 'uploads#show', constraints: { model: /personal_snippet/, id: /\d+/, filename: /[^\/]+/ } + # show temporary uploads + get 'temp/:secret/:filename', + to: 'uploads#show', + constraints: { filename: /[^\/]+/ } + # Appearance get "system/:model/:mounted_as/:id/:filename", to: "uploads#show", @@ -20,7 +25,7 @@ scope path: :uploads do constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ } # create uploads for models, snippets (notes) available for now - post ':model/:id/', + post ':model', to: 'uploads#create', constraints: { model: /personal_snippet/, id: /\d+/ }, as: 'upload' diff --git a/config/routes/user.rb b/config/routes/user.rb index 0f3bec9cf58..d7bef830a8a 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -11,6 +11,7 @@ devise_scope :user do get '/users/almost_there' => 'confirmations#almost_there' end +<<<<<<< HEAD constraints(UserUrlConstrainer.new) do # Get all keys of user get ':username.keys' => 'profiles/keys#get_keys', constraints: { username: Gitlab::Regex.root_namespace_route_regex } @@ -24,6 +25,9 @@ constraints(UserUrlConstrainer.new) do end scope(constraints: { username: Gitlab::Regex.root_namespace_route_regex }) do +======= +scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) do +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 scope(path: 'users/:username', as: :user, controller: :users) do @@ -34,7 +38,7 @@ scope(constraints: { username: Gitlab::Regex.root_namespace_route_regex }) do get :contributed, as: :contributed_projects get :snippets get :exists - get '/', to: redirect('/%{username}') + get '/', to: redirect('/%{username}'), as: nil end # Compatibility with old routing @@ -46,3 +50,15 @@ scope(constraints: { username: Gitlab::Regex.root_namespace_route_regex }) do get '/u/:username/snippets', to: redirect('/users/%{username}/snippets') get '/u/:username/contributed', to: redirect('/users/%{username}/contributed') end + +constraints(UserUrlConstrainer.new) do + # Get all keys of user + get ':username.keys' => 'profiles/keys#get_keys', constraints: { username: Gitlab::PathRegex.root_namespace_route_regex } + + scope(path: ':username', + as: :user, + constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }, + controller: :users) do + get '/', action: :show + end +end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 433381e79d3..93df2d6f5ff 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -40,12 +40,12 @@ - [expire_build_instance_artifacts, 1] - [group_destroy, 1] - [irker, 1] + - [namespaceless_project_destroy, 1] - [project_cache, 1] - [project_destroy, 1] - [project_export, 1] - - [project_web_hook, 1] + - [web_hook, 1] - [repository_check, 1] - - [system_hook, 1] - [git_garbage_collect, 1] - [reactive_caching, 1] - [cronjob, 1] diff --git a/config/webpack.config.js b/config/webpack.config.js index 8ca2e88907f..01d5d87b168 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -5,6 +5,7 @@ var path = require('path'); var webpack = require('webpack'); var StatsPlugin = require('stats-webpack-plugin'); var CompressionPlugin = require('compression-webpack-plugin'); +var NameAllModulesPlugin = require('name-all-modules-plugin'); var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; var WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin'); @@ -15,6 +16,7 @@ var DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost'; var DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808; var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; var WEBPACK_REPORT = process.env.WEBPACK_REPORT; +var NO_COMPRESSION = process.env.NO_COMPRESSION; var config = { // because sqljs requires fs. @@ -23,6 +25,7 @@ var config = { }, context: path.join(ROOT_PATH, 'app/assets/javascripts'), entry: { + balsamiq_viewer: './blob/balsamiq_viewer.js', blob: './blob_edit/blob_bundle.js', boards: './boards/boards_bundle.js', common: './commons/index.js', @@ -39,6 +42,7 @@ var config = { group: './group.js', groups_list: './groups_list.js', issue_show: './issue_show/index.js', + integrations: './integrations', locale: './locale/index.js', main: './main.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', @@ -47,8 +51,7 @@ var config = { notebook_viewer: './blob/notebook_viewer.js', pdf_viewer: './blob/pdf_viewer.js', pipelines: './pipelines/index.js', - balsamiq_viewer: './blob/balsamiq_viewer.js', - pipelines_graph: './pipelines/graph_bundle.js', + pipelines_details: './pipelines/pipeline_details_bundle.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', @@ -69,11 +72,10 @@ var config = { output: { path: path.join(ROOT_PATH, 'public/assets/webpack'), publicPath: '/assets/webpack/', - filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js' + filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js', + chunkFilename: IS_PRODUCTION ? '[name].[chunkhash].chunk.js' : '[name].chunk.js', }, - devtool: 'cheap-module-source-map', - module: { rules: [ { @@ -90,17 +92,17 @@ var config = { loader: 'raw-loader', }, { - test: /\.gif$/, + test: /\.(gif|png)$/, loader: 'url-loader', - query: { mimetype: 'image/gif' }, + options: { limit: 2048 }, }, { - test: /\.(worker\.js|pdf)$/, + test: /\.(worker\.js|pdf|bmpr)$/, exclude: /node_modules/, loader: 'file-loader', }, { - test: /locale\/[a-z]+\/(.*)\.js$/, + test: /locale\/\w+\/(.*)\.js$/, loader: 'exports-loader?locales', }, ] @@ -126,10 +128,20 @@ var config = { jQuery: 'jquery', }), - // use deterministic module ids in all environments - IS_PRODUCTION ? - new webpack.HashedModuleIdsPlugin() : - new webpack.NamedModulesPlugin(), + // assign deterministic module ids + new webpack.NamedModulesPlugin(), + new NameAllModulesPlugin(), + + // assign deterministic chunk ids + new webpack.NamedChunksPlugin((chunk) => { + if (chunk.name) { + return chunk.name; + } + return chunk.modules.map((m) => { + var chunkPath = m.request.split('!').pop(); + return path.relative(m.context, chunkPath); + }).join('_'); + }), // create cacheable common library bundle for all vue chunks new webpack.optimize.CommonsChunkPlugin({ @@ -148,8 +160,12 @@ var config = { 'notebook_viewer', 'pdf_viewer', 'pipelines', +<<<<<<< HEAD 'balsamiq_viewer', 'pipelines_graph', +======= + 'pipelines_details', +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 'schedule_form', 'schedules_index', 'sidebar', @@ -172,15 +188,7 @@ var config = { // create cacheable common library bundles new webpack.optimize.CommonsChunkPlugin({ - names: ['main', 'common', 'runtime'], - }), - - // locale common library - new webpack.optimize.CommonsChunkPlugin({ - name: 'locale', - chunks: [ - 'cycle_analytics', - ], + names: ['main', 'locale', 'common', 'runtime'], }), ], @@ -191,6 +199,7 @@ var config = { 'emojis': path.join(ROOT_PATH, 'fixtures/emojis'), 'empty_states': path.join(ROOT_PATH, 'app/views/shared/empty_states'), 'icons': path.join(ROOT_PATH, 'app/views/shared/icons'), + 'images': path.join(ROOT_PATH, 'app/assets/images'), 'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'), 'vue$': 'vue/dist/vue.esm.js', } @@ -210,11 +219,18 @@ if (IS_PRODUCTION) { }), new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify('production') } - }), - new CompressionPlugin({ - asset: '[path].gz[query]', }) ); + + // zopfli requires a lot of compute time and is disabled in CI + if (!NO_COMPRESSION) { + config.plugins.push( + new CompressionPlugin({ + asset: '[path].gz[query]', + algorithm: 'zopfli', + }) + ); + } } if (IS_DEV_SERVER) { diff --git a/db/fixtures/development/04_project.rb b/db/fixtures/development/04_project.rb index c2b8f7ba819..6553c5d457a 100644 --- a/db/fixtures/development/04_project.rb +++ b/db/fixtures/development/04_project.rb @@ -71,7 +71,9 @@ Sidekiq::Testing.inline! do # hook won't run until after the fixture is loaded. That is too late # since the Sidekiq::Testing block has already exited. Force clearing # the `after_commit` queue to ensure the job is run now. - project.send(:_run_after_commit_queue) + Sidekiq::Worker.skipping_transaction_check do + project.send(:_run_after_commit_queue) + end if project.valid? && project.valid_repo? print '.' diff --git a/db/fixtures/development/11_keys.rb b/db/fixtures/development/11_keys.rb index 51e22137d6f..c405ecfdaf3 100644 --- a/db/fixtures/development/11_keys.rb +++ b/db/fixtures/development/11_keys.rb @@ -1,17 +1,26 @@ require './spec/support/sidekiq' + # Creating keys runs a gitlab-shell worker. Since we may not have the right # gitlab-shell path set (yet) we need to disable this for these fixtures. Sidekiq::Testing.disable! do Gitlab::Seeder.quiet do + # We want to run `add_to_shell` immediately instead of after the commit, so + # that it falls under `Sidekiq::Testing.disable!`. + Key.skip_callback(:commit, :after, :add_to_shell) + User.first(10).each do |user| key = "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt#{user.id + 100}6k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=" - user.keys.create( + key = user.keys.create( title: "Sample key #{user.id}", key: key ) + Sidekiq::Worker.skipping_transaction_check do + key.add_to_shell + end + print '.' end end diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb index 3c42f7db6d5..5de5339b70e 100644 --- a/db/fixtures/development/14_pipelines.rb +++ b/db/fixtures/development/14_pipelines.rb @@ -98,7 +98,7 @@ class Gitlab::Seeder::Pipelines def create_pipeline!(project, ref, commit) - project.pipelines.create(sha: commit.id, ref: ref) + project.pipelines.create(sha: commit.id, ref: ref, source: :push) end def build_create!(pipeline, opts = {}) @@ -112,6 +112,10 @@ class Gitlab::Seeder::Pipelines setup_artifacts(build) setup_build_log(build) + + build.project.environments. + find_or_create_by(name: build.expanded_environment_name) + build.save end end diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb index 0d7eb1a7c93..7c1d758dada 100644 --- a/db/fixtures/development/17_cycle_analytics.rb +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -190,7 +190,7 @@ class Gitlab::Seeder::CycleAnalytics 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 = service.execute(:push, ignore_skip_ci: true, save_on_errors: false) pipeline.run! Timecop.travel rand(1..6).hours.from_now @@ -212,12 +212,9 @@ class Gitlab::Seeder::CycleAnalytics 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 + job = merge_request.head_pipeline.builds.where.not(environment: nil).last + + CreateDeploymentService.new(job).execute end end end diff --git a/db/fixtures/development/21_conversational_development_index_metrics.rb b/db/fixtures/development/21_conversational_development_index_metrics.rb new file mode 100644 index 00000000000..4cd0a82ed1a --- /dev/null +++ b/db/fixtures/development/21_conversational_development_index_metrics.rb @@ -0,0 +1,40 @@ +Gitlab::Seeder.quiet do + conversational_development_index_metric = ConversationalDevelopmentIndex::Metric.new( + leader_issues: 10.2, + instance_issues: 3.2, + + leader_notes: 25.3, + instance_notes: 23.2, + + leader_milestones: 16.2, + instance_milestones: 5.5, + + leader_boards: 5.2, + instance_boards: 3.2, + + leader_merge_requests: 5.2, + instance_merge_requests: 3.2, + + leader_ci_pipelines: 25.1, + instance_ci_pipelines: 21.3, + + leader_environments: 3.3, + instance_environments: 2.2, + + leader_deployments: 41.3, + instance_deployments: 15.2, + + leader_projects_prometheus_active: 0.31, + instance_projects_prometheus_active: 0.30, + + leader_service_desk_issues: 15.8, + instance_service_desk_issues: 15.1 + ) + + if conversational_development_index_metric.save + print '.' + else + puts conversational_development_index_metric.errors.full_messages + print 'F' + end +end diff --git a/db/fixtures/production/010_settings.rb b/db/fixtures/production/010_settings.rb index 5522f31629a..7626cdb0b9c 100644 --- a/db/fixtures/production/010_settings.rb +++ b/db/fixtures/production/010_settings.rb @@ -1,16 +1,26 @@ -if ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN'].present? - settings = ApplicationSetting.current || ApplicationSetting.create_from_defaults - settings.set_runners_registration_token(ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN']) - +def save(settings, topic) if settings.save - puts "Saved Runner Registration Token".color(:green) + puts "Saved #{topic}".color(:green) else - puts "Could not save Runner Registration Token".color(:red) + puts "Could not save #{topic}".color(:red) puts settings.errors.full_messages.map do |message| puts "--> #{message}".color(:red) end puts - exit 1 + exit(1) end end + +if ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN'].present? + settings = Gitlab::CurrentSettings.current_application_settings + settings.set_runners_registration_token(ENV['GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN']) + save(settings, 'Runner Registration Token') +end + +if ENV['GITLAB_PROMETHEUS_METRICS_ENABLED'].present? + settings = Gitlab::CurrentSettings.current_application_settings + value = Gitlab::Utils.to_boolean(ENV['GITLAB_PROMETHEUS_METRICS_ENABLED']) || false + settings.prometheus_metrics_enabled = value + save(settings, 'Prometheus metrics enabled flag') +end diff --git a/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb b/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb index bd0463886bc..4d6a61bd614 100644 --- a/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb +++ b/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class SetMissingStageOnCiBuilds < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb b/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb index 1eb99feb40c..b2a2ce41391 100644 --- a/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb +++ b/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class DropAndReaddHasExternalWikiInProjects < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20160810142633_remove_redundant_indexes.rb b/db/migrate/20160810142633_remove_redundant_indexes.rb index d7ab022d7bc..ea7d1f9a436 100644 --- a/db/migrate/20160810142633_remove_redundant_indexes.rb +++ b/db/migrate/20160810142633_remove_redundant_indexes.rb @@ -69,7 +69,7 @@ class RemoveRedundantIndexes < ActiveRecord::Migration [: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'], + [:users, 'index_users_on_created_at_and_id'] ] transaction do diff --git a/db/migrate/20160829114652_add_markdown_cache_columns.rb b/db/migrate/20160829114652_add_markdown_cache_columns.rb index 9cb44dfa9f9..6ad7237f4cd 100644 --- a/db/migrate/20160829114652_add_markdown_cache_columns.rb +++ b/db/migrate/20160829114652_add_markdown_cache_columns.rb @@ -25,7 +25,7 @@ class AddMarkdownCacheColumns < ActiveRecord::Migration notes: [:note], projects: [:description], releases: [:description], - snippets: [:title, :content], + snippets: [:title, :content] }.freeze def change diff --git a/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb b/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb index f1a1f001cb3..febd2c0e65e 100644 --- a/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb +++ b/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class SetConfidentialIssuesEventsOnWebhooks < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20160919144305_add_type_to_labels.rb b/db/migrate/20160919144305_add_type_to_labels.rb index 66172bda6ff..2d2725ccf59 100644 --- a/db/migrate/20160919144305_add_type_to_labels.rb +++ b/db/migrate/20160919144305_add_type_to_labels.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class AddTypeToLabels < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20161018124658_make_project_owners_masters.rb b/db/migrate/20161018124658_make_project_owners_masters.rb index a576bb7b622..fe11699c196 100644 --- a/db/migrate/20161018124658_make_project_owners_masters.rb +++ b/db/migrate/20161018124658_make_project_owners_masters.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class MakeProjectOwnersMasters < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb b/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb index 50ad7437227..c7cada6dfc5 100644 --- a/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb +++ b/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class RenameSlackAndMattermostNotificationServices < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20170317203554_index_routes_path_for_like.rb b/db/migrate/20170317203554_index_routes_path_for_like.rb index 7ac09b2abe5..8d3609135d0 100644 --- a/db/migrate/20170317203554_index_routes_path_for_like.rb +++ b/db/migrate/20170317203554_index_routes_path_for_like.rb @@ -21,9 +21,8 @@ class IndexRoutesPathForLike < ActiveRecord::Migration def down return unless Gitlab::Database.postgresql? + return unless index_exists?(:routes, :path, name: INDEX_NAME) - if index_exists?(:routes, :path, name: INDEX_NAME) - execute("DROP INDEX CONCURRENTLY #{INDEX_NAME};") - end + remove_concurrent_index_by_name(:routes, INDEX_NAME) end end diff --git a/db/migrate/20170320173259_migrate_assignees.rb b/db/migrate/20170320173259_migrate_assignees.rb index 23e7500a32d..7b61e811317 100644 --- a/db/migrate/20170320173259_migrate_assignees.rb +++ b/db/migrate/20170320173259_migrate_assignees.rb @@ -1,6 +1,4 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. - +# rubocop:disable Migration/UpdateColumnInBatches class MigrateAssignees < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb b/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb index 9d4380ef960..84635fa39b9 100644 --- a/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb +++ b/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb @@ -11,13 +11,7 @@ class RemoveIndexForUsersCurrentSignInAt < ActiveRecord::Migration disable_ddl_transaction! def up - if index_exists? :users, :current_sign_in_at - if Gitlab::Database.postgresql? - execute 'DROP INDEX CONCURRENTLY index_users_on_current_sign_in_at;' - else - remove_concurrent_index :users, :current_sign_in_at - end - end + remove_concurrent_index :users, :current_sign_in_at end def down diff --git a/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb b/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb index 6116ca59ee4..1587eee06ae 100644 --- a/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb +++ b/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb @@ -4,10 +4,20 @@ class RemoveForeighKeyCiTriggerSchedules < ActiveRecord::Migration DOWNTIME = false def up - remove_foreign_key :ci_trigger_schedules, column: :trigger_id + if fk_on_trigger_schedules? + remove_foreign_key :ci_trigger_schedules, column: :trigger_id + end end def down # no op, the foreign key should not have been here end + + private + + # Not made more generic and lifted to the helpers as Rails 5 will provide + # such an API + def fk_on_trigger_schedules? + connection.foreign_keys(:ci_trigger_schedules).include?("ci_triggers") + end end diff --git a/db/migrate/20170427103502_create_web_hook_logs.rb b/db/migrate/20170427103502_create_web_hook_logs.rb new file mode 100644 index 00000000000..3643c52180c --- /dev/null +++ b/db/migrate/20170427103502_create_web_hook_logs.rb @@ -0,0 +1,22 @@ +# rubocop:disable all +class CreateWebHookLogs < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :web_hook_logs do |t| + t.references :web_hook, null: false, index: true, foreign_key: { on_delete: :cascade } + + t.string :trigger + t.string :url + t.text :request_headers + t.text :request_data + t.text :response_headers + t.text :response_body + t.string :response_status + t.float :execution_duration + t.string :internal_error_message + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20170502065653_make_auto_cancel_pending_pipelines_on_by_default.rb b/db/migrate/20170502065653_make_auto_cancel_pending_pipelines_on_by_default.rb new file mode 100644 index 00000000000..03bf626a08a --- /dev/null +++ b/db/migrate/20170502065653_make_auto_cancel_pending_pipelines_on_by_default.rb @@ -0,0 +1,13 @@ +class MakeAutoCancelPendingPipelinesOnByDefault < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + change_column_default(:projects, :auto_cancel_pending_pipelines, 1) + end + + def down + change_column_default(:projects, :auto_cancel_pending_pipelines, 0) + end +end diff --git a/db/migrate/20170502135553_create_index_ci_pipelines_auto_canceled_by_id.rb b/db/migrate/20170502135553_create_index_ci_pipelines_auto_canceled_by_id.rb new file mode 100644 index 00000000000..b64d7e0e3f6 --- /dev/null +++ b/db/migrate/20170502135553_create_index_ci_pipelines_auto_canceled_by_id.rb @@ -0,0 +1,21 @@ +class CreateIndexCiPipelinesAutoCanceledById < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + # MySQL would already have the index + unless index_exists?(:ci_pipelines, :auto_canceled_by_id) + add_concurrent_index(:ci_pipelines, :auto_canceled_by_id) + end + end + + def down + # We cannot remove index for MySQL because it's needed for foreign key + if Gitlab::Database.postgresql? + remove_concurrent_index(:ci_pipelines, :auto_canceled_by_id) + end + end +end diff --git a/db/migrate/20170502140503_create_index_ci_builds_auto_canceled_by_id.rb b/db/migrate/20170502140503_create_index_ci_builds_auto_canceled_by_id.rb new file mode 100644 index 00000000000..0a8d2c8ff61 --- /dev/null +++ b/db/migrate/20170502140503_create_index_ci_builds_auto_canceled_by_id.rb @@ -0,0 +1,21 @@ +class CreateIndexCiBuildsAutoCanceledById < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + # MySQL would already have the index + unless index_exists?(:ci_builds, :auto_canceled_by_id) + add_concurrent_index(:ci_builds, :auto_canceled_by_id) + end + end + + def down + # We cannot remove index for MySQL because it's needed for foreign key + if Gitlab::Database.postgresql? + remove_concurrent_index(:ci_builds, :auto_canceled_by_id) + end + end +end diff --git a/db/migrate/20170503023315_add_repository_update_events_to_web_hooks.rb b/db/migrate/20170503023315_add_repository_update_events_to_web_hooks.rb new file mode 100644 index 00000000000..0faea87a962 --- /dev/null +++ b/db/migrate/20170503023315_add_repository_update_events_to_web_hooks.rb @@ -0,0 +1,15 @@ +class AddRepositoryUpdateEventsToWebHooks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :web_hooks, :repository_update_events, :boolean, default: false, allow_null: false + end + + def down + remove_column :web_hooks, :repository_update_events + end +end diff --git a/db/migrate/20170503114228_add_description_to_snippets.rb b/db/migrate/20170503114228_add_description_to_snippets.rb new file mode 100644 index 00000000000..3fc960b2da5 --- /dev/null +++ b/db/migrate/20170503114228_add_description_to_snippets.rb @@ -0,0 +1,12 @@ +class AddDescriptionToSnippets < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def change + add_column :snippets, :description, :text + add_column :snippets, :description_html, :text + end +end diff --git a/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb b/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb index 5b8b6c828be..8eb20faa03a 100644 --- a/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb +++ b/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb @@ -21,9 +21,8 @@ class IndexRedirectRoutesPathForLike < ActiveRecord::Migration def down return unless Gitlab::Database.postgresql? + return unless index_exists?(:redirect_routes, :path, name: INDEX_NAME) - if index_exists?(:redirect_routes, :path, name: INDEX_NAME) - execute("DROP INDEX CONCURRENTLY #{INDEX_NAME};") - end + remove_concurrent_index_by_name(:redirect_routes, INDEX_NAME) end end diff --git a/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb b/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb new file mode 100644 index 00000000000..8fc6e380a77 --- /dev/null +++ b/db/migrate/20170507205316_add_head_pipeline_id_to_merge_requests.rb @@ -0,0 +1,7 @@ +class AddHeadPipelineIdToMergeRequests < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :merge_requests, :head_pipeline_id, :integer + end +end diff --git a/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb b/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb new file mode 100644 index 00000000000..41c687a4f6e --- /dev/null +++ b/db/migrate/20170508153950_add_not_null_contraints_to_ci_variables.rb @@ -0,0 +1,12 @@ +class AddNotNullContraintsToCiVariables < ActiveRecord::Migration + DOWNTIME = false + + def up + change_column(:ci_variables, :key, :string, null: false) + change_column(:ci_variables, :project_id, :integer, null: false) + end + + def down + # no op + end +end diff --git a/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb b/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb new file mode 100644 index 00000000000..20ecaa2c36c --- /dev/null +++ b/db/migrate/20170508190732_add_foreign_key_to_ci_variables.rb @@ -0,0 +1,24 @@ +class AddForeignKeyToCiVariables < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + execute <<~SQL + DELETE FROM ci_variables + WHERE NOT EXISTS ( + SELECT true + FROM projects + WHERE projects.id = ci_variables.project_id + ) + SQL + + add_concurrent_foreign_key(:ci_variables, :projects, column: :project_id) + end + + def down + remove_foreign_key(:ci_variables, column: :project_id) + end +end diff --git a/db/migrate/20170511082759_rename_web_hooks_build_events_to_job_events.rb b/db/migrate/20170511082759_rename_web_hooks_build_events_to_job_events.rb new file mode 100644 index 00000000000..a2320a911b7 --- /dev/null +++ b/db/migrate/20170511082759_rename_web_hooks_build_events_to_job_events.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 RenameWebHooksBuildEventsToJobEvents < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + rename_column_concurrently :web_hooks, :build_events, :job_events + end + + def down + cleanup_concurrent_column_rename :web_hooks, :job_events, :build_events + end +end diff --git a/db/migrate/20170511083824_rename_services_build_events_to_job_events.rb b/db/migrate/20170511083824_rename_services_build_events_to_job_events.rb new file mode 100644 index 00000000000..303d47078e7 --- /dev/null +++ b/db/migrate/20170511083824_rename_services_build_events_to_job_events.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 RenameServicesBuildEventsToJobEvents < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + rename_column_concurrently :services, :build_events, :job_events + end + + def down + cleanup_concurrent_column_rename :services, :job_events, :build_events + end +end diff --git a/db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb b/db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb new file mode 100644 index 00000000000..6ec2ed712b9 --- /dev/null +++ b/db/migrate/20170519102115_add_prometheus_settings_to_metrics_settings.rb @@ -0,0 +1,16 @@ +class AddPrometheusSettingsToMetricsSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + DOWNTIME = false + + def up + add_column_with_default(:application_settings, :prometheus_metrics_enabled, :boolean, + default: false, allow_null: false) + end + + def down + remove_column(:application_settings, :prometheus_metrics_enabled) + end +end diff --git a/db/migrate/20170521184006_add_change_position_to_notes.rb b/db/migrate/20170521184006_add_change_position_to_notes.rb new file mode 100644 index 00000000000..219ed1ade4c --- /dev/null +++ b/db/migrate/20170521184006_add_change_position_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 AddChangePositionToNotes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :notes, :change_position, :text + end +end diff --git a/db/migrate/20170523091700_add_rss_token_to_users.rb b/db/migrate/20170523091700_add_rss_token_to_users.rb new file mode 100644 index 00000000000..06a85f6ac3d --- /dev/null +++ b/db/migrate/20170523091700_add_rss_token_to_users.rb @@ -0,0 +1,19 @@ +class AddRssTokenToUsers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column :users, :rss_token, :string + + add_concurrent_index :users, :rss_token + end + + def down + remove_concurrent_index :users, :rss_token if index_exists? :users, :rss_token + + remove_column :users, :rss_token + end +end diff --git a/db/migrate/20170523121229_create_conversational_development_index_metrics.rb b/db/migrate/20170523121229_create_conversational_development_index_metrics.rb new file mode 100644 index 00000000000..9f9ec526055 --- /dev/null +++ b/db/migrate/20170523121229_create_conversational_development_index_metrics.rb @@ -0,0 +1,39 @@ +class CreateConversationalDevelopmentIndexMetrics < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :conversational_development_index_metrics do |t| + t.float :leader_issues, null: false + t.float :instance_issues, null: false + + t.float :leader_notes, null: false + t.float :instance_notes, null: false + + t.float :leader_milestones, null: false + t.float :instance_milestones, null: false + + t.float :leader_boards, null: false + t.float :instance_boards, null: false + + t.float :leader_merge_requests, null: false + t.float :instance_merge_requests, null: false + + t.float :leader_ci_pipelines, null: false + t.float :instance_ci_pipelines, null: false + + t.float :leader_environments, null: false + t.float :instance_environments, null: false + + t.float :leader_deployments, null: false + t.float :instance_deployments, null: false + + t.float :leader_projects_prometheus_active, null: false + t.float :instance_projects_prometheus_active, null: false + + t.float :leader_service_desk_issues, null: false + t.float :instance_service_desk_issues, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20170524125940_add_source_to_ci_pipeline.rb b/db/migrate/20170524125940_add_source_to_ci_pipeline.rb new file mode 100644 index 00000000000..1fa3d48037b --- /dev/null +++ b/db/migrate/20170524125940_add_source_to_ci_pipeline.rb @@ -0,0 +1,9 @@ +class AddSourceToCiPipeline < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_pipelines, :source, :integer + end +end diff --git a/db/migrate/20170524161101_add_protected_to_ci_variables.rb b/db/migrate/20170524161101_add_protected_to_ci_variables.rb new file mode 100644 index 00000000000..99d4861e889 --- /dev/null +++ b/db/migrate/20170524161101_add_protected_to_ci_variables.rb @@ -0,0 +1,15 @@ +class AddProtectedToCiVariables < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:ci_variables, :protected, :boolean, default: false) + end + + def down + remove_column(:ci_variables, :protected) + end +end diff --git a/db/migrate/20170525132202_create_pipeline_stages.rb b/db/migrate/20170525132202_create_pipeline_stages.rb new file mode 100644 index 00000000000..25656f2a2c2 --- /dev/null +++ b/db/migrate/20170525132202_create_pipeline_stages.rb @@ -0,0 +1,25 @@ +class CreatePipelineStages < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + create_table :ci_stages do |t| + t.integer :project_id + t.integer :pipeline_id + t.timestamps null: true + t.string :name + end + + add_concurrent_foreign_key :ci_stages, :projects, column: :project_id, on_delete: :cascade + add_concurrent_foreign_key :ci_stages, :ci_pipelines, column: :pipeline_id, on_delete: :cascade + add_concurrent_index :ci_stages, :project_id + add_concurrent_index :ci_stages, :pipeline_id + end + + def down + drop_table :ci_stages + end +end diff --git a/db/migrate/20170525174156_create_feature_tables.rb b/db/migrate/20170525174156_create_feature_tables.rb new file mode 100644 index 00000000000..a083c89c85f --- /dev/null +++ b/db/migrate/20170525174156_create_feature_tables.rb @@ -0,0 +1,26 @@ +class CreateFeatureTables < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def self.up + create_table :features do |t| + t.string :key, null: false + t.timestamps null: false + end + add_index :features, :key, unique: true + + create_table :feature_gates do |t| + t.string :feature_key, null: false + t.string :key, null: false + t.string :value + t.timestamps null: false + end + add_index :feature_gates, [:feature_key, :key, :value], unique: true + end + + def self.down + drop_table :feature_gates + drop_table :features + end +end diff --git a/db/migrate/20170526185602_add_stage_id_to_ci_builds.rb b/db/migrate/20170526185602_add_stage_id_to_ci_builds.rb new file mode 100644 index 00000000000..d5675d5828b --- /dev/null +++ b/db/migrate/20170526185602_add_stage_id_to_ci_builds.rb @@ -0,0 +1,21 @@ +class AddStageIdToCiBuilds < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column :ci_builds, :stage_id, :integer + + add_concurrent_foreign_key :ci_builds, :ci_stages, column: :stage_id, on_delete: :cascade + add_concurrent_index :ci_builds, :stage_id + end + + def down + remove_foreign_key :ci_builds, column: :stage_id + remove_concurrent_index :ci_builds, :stage_id + + remove_column :ci_builds, :stage_id, :integer + end +end diff --git a/db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb b/db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb new file mode 100644 index 00000000000..470c3b8166c --- /dev/null +++ b/db/migrate/20170531202042_rename_users_ldap_email_to_external_email.rb @@ -0,0 +1,15 @@ +class RenameUsersLdapEmailToExternalEmail < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + rename_column_concurrently :users, :ldap_email, :external_email + end + + def down + cleanup_concurrent_column_rename :users, :external_email, :ldap_email + end +end diff --git a/db/migrate/20170603200744_add_email_provider_to_users.rb b/db/migrate/20170603200744_add_email_provider_to_users.rb new file mode 100644 index 00000000000..ed90af9aadc --- /dev/null +++ b/db/migrate/20170603200744_add_email_provider_to_users.rb @@ -0,0 +1,9 @@ +class AddEmailProviderToUsers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :users, :email_provider, :string + end +end diff --git a/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb b/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb index b518038e93a..82f8147547e 100644 --- a/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb +++ b/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb @@ -1,6 +1,4 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. - +# rubocop:disable Migration/UpdateColumnInBatches class ResetUsersAuthorizedProjectsPopulated < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb index b61dd7cfc61..b1c9eed1148 100644 --- a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb +++ b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb @@ -1,6 +1,4 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. - +# rubocop:disable Migration/UpdateColumnInBatches class ResetRelativePositionForIssue < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb b/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb new file mode 100644 index 00000000000..3c13a3d2518 --- /dev/null +++ b/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb @@ -0,0 +1,16 @@ +# rubocop:disable Migration/UpdateColumnInBatches +class EnableAutoCancelPendingPipelinesForAll < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + DOWNTIME = false + + def up + update_column_in_batches(:projects, :auto_cancel_pending_pipelines, 1) + end + + def down + # Nothing we can do! + end +end diff --git a/db/post_migrate/20170502101023_cleanup_namespaceless_pending_delete_projects.rb b/db/post_migrate/20170502101023_cleanup_namespaceless_pending_delete_projects.rb new file mode 100644 index 00000000000..ce52de91cdd --- /dev/null +++ b/db/post_migrate/20170502101023_cleanup_namespaceless_pending_delete_projects.rb @@ -0,0 +1,47 @@ +# This is the counterpart of RequeuePendingDeleteProjects and cleans all +# projects with `pending_delete = true` and that do not have a namespace. +class CleanupNamespacelessPendingDeleteProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + @offset = 0 + + loop do + ids = pending_delete_batch + + break if ids.empty? + + args = ids.map { |id| Array(id) } + + NamespacelessProjectDestroyWorker.bulk_perform_async(args) + + @offset += 1 + end + end + + def down + # noop + end + + private + + def pending_delete_batch + connection.exec_query(find_batch).map{ |row| row['id'].to_i } + end + + BATCH_SIZE = 5000 + + def find_batch + projects = Arel::Table.new(:projects) + projects.project(projects[:id]). + where(projects[:pending_delete].eq(true)). + where(projects[:namespace_id].eq(nil)). + skip(@offset * BATCH_SIZE). + take(BATCH_SIZE). + to_sql + end +end diff --git a/db/post_migrate/20170503004427_update_retried_for_ci_build.rb b/db/post_migrate/20170503004427_update_retried_for_ci_build.rb index 3a4d6c4916b..712616425a1 100644 --- a/db/post_migrate/20170503004427_update_retried_for_ci_build.rb +++ b/db/post_migrate/20170503004427_update_retried_for_ci_build.rb @@ -21,7 +21,11 @@ class UpdateRetriedForCiBuild < ActiveRecord::Migration private def up_mysql +<<<<<<< HEAD # This is a trick to overcome MySQL limitation: +======= + # This is a trick to overcome MySQL limitation: +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 # Mysql2::Error: Table 'ci_builds' is specified twice, both as a target for 'UPDATE' and as a separate source for data # However, this leads to create a temporary table from `max(ci_builds.id)` which is slow and do full database update execute <<-SQL.strip_heredoc diff --git a/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb b/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb new file mode 100644 index 00000000000..bc3850c0c23 --- /dev/null +++ b/db/post_migrate/20170508170547_add_head_pipeline_for_each_merge_request.rb @@ -0,0 +1,25 @@ +class AddHeadPipelineForEachMergeRequest < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + disable_statement_timeout + + pipelines = Arel::Table.new(:ci_pipelines) + merge_requests = Arel::Table.new(:merge_requests) + + head_id = pipelines. + project(Arel::Nodes::NamedFunction.new('max', [pipelines[:id]])). + from(pipelines). + where(pipelines[:ref].eq(merge_requests[:source_branch])). + where(pipelines[:project_id].eq(merge_requests[:source_project_id])) + + sub_query = Arel::Nodes::SqlLiteral.new(Arel::Nodes::Grouping.new(head_id).to_sql) + + update_column_in_batches(:merge_requests, :head_pipeline_id, sub_query) + end + + def down + end +end diff --git a/db/post_migrate/20170510101043_add_foreign_key_on_pipeline_schedule_owner.rb b/db/post_migrate/20170510101043_add_foreign_key_on_pipeline_schedule_owner.rb new file mode 100644 index 00000000000..6a870f08e89 --- /dev/null +++ b/db/post_migrate/20170510101043_add_foreign_key_on_pipeline_schedule_owner.rb @@ -0,0 +1,35 @@ +class AddForeignKeyOnPipelineScheduleOwner < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + execute <<-SQL + UPDATE ci_pipeline_schedules + SET owner_id = NULL + WHERE NOT EXISTS ( + SELECT true + FROM users + WHERE ci_pipeline_schedules.owner_id = users.id + ) + SQL + + add_concurrent_foreign_key(:ci_pipeline_schedules, :users, column: :owner_id, on_delete: on_delete) + end + + def down + remove_foreign_key(:ci_pipeline_schedules, column: :owner_id) + end + + private + + def on_delete + if Gitlab::Database.mysql? + :nullify + else + 'SET NULL' + end + end +end diff --git a/db/post_migrate/20170511100900_cleanup_rename_web_hooks_build_events_to_job_events.rb b/db/post_migrate/20170511100900_cleanup_rename_web_hooks_build_events_to_job_events.rb new file mode 100644 index 00000000000..281be90163a --- /dev/null +++ b/db/post_migrate/20170511100900_cleanup_rename_web_hooks_build_events_to_job_events.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 CleanupRenameWebHooksBuildEventsToJobEvents < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + cleanup_concurrent_column_rename :web_hooks, :build_events, :job_events + end + + def down + rename_column_concurrently :web_hooks, :job_events, :build_events + end +end diff --git a/db/post_migrate/20170511101000_cleanup_rename_services_build_events_to_job_events.rb b/db/post_migrate/20170511101000_cleanup_rename_services_build_events_to_job_events.rb new file mode 100644 index 00000000000..5d26df5688f --- /dev/null +++ b/db/post_migrate/20170511101000_cleanup_rename_services_build_events_to_job_events.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 CleanupRenameServicesBuildEventsToJobEvents < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + cleanup_concurrent_column_rename :services, :build_events, :job_events + end + + def down + rename_column_concurrently :services, :job_events, :build_events + end +end diff --git a/db/post_migrate/20170523083112_migrate_old_artifacts.rb b/db/post_migrate/20170523083112_migrate_old_artifacts.rb new file mode 100644 index 00000000000..f2690bd0017 --- /dev/null +++ b/db/post_migrate/20170523083112_migrate_old_artifacts.rb @@ -0,0 +1,72 @@ +class MigrateOldArtifacts < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + # This uses special heuristic to find potential candidates for data migration + # Read more about this here: https://gitlab.com/gitlab-org/gitlab-ce/issues/32036#note_30422345 + + def up + builds_with_artifacts.find_each do |build| + build.migrate_artifacts! + end + end + + def down + end + + private + + def builds_with_artifacts + Build.with_artifacts + .joins('JOIN projects ON projects.id = ci_builds.project_id') + .where('ci_builds.id < ?', min_id) + .where('projects.ci_id IS NOT NULL') + .select('id', 'created_at', 'project_id', 'projects.ci_id AS ci_id') + end + + def min_id + Build.joins('JOIN projects ON projects.id = ci_builds.project_id') + .where('projects.ci_id IS NULL') + .pluck('coalesce(min(ci_builds.id), 0)') + .first + end + + class Build < ActiveRecord::Base + self.table_name = 'ci_builds' + + scope :with_artifacts, -> { where.not(artifacts_file: [nil, '']) } + + def migrate_artifacts! + return unless File.exist?(source_artifacts_path) + return if File.exist?(target_artifacts_path) + + ensure_target_path + + FileUtils.move(source_artifacts_path, target_artifacts_path) + end + + private + + def source_artifacts_path + @source_artifacts_path ||= + File.join(Gitlab.config.artifacts.path, + created_at.utc.strftime('%Y_%m'), + ci_id.to_s, id.to_s) + end + + def target_artifacts_path + @target_artifacts_path ||= + File.join(Gitlab.config.artifacts.path, + created_at.utc.strftime('%Y_%m'), + project_id.to_s, id.to_s) + end + + def ensure_target_path + directory = File.dirname(target_artifacts_path) + FileUtils.mkdir_p(directory) unless Dir.exist?(directory) + end + end +end diff --git a/db/post_migrate/20170526185842_migrate_pipeline_stages.rb b/db/post_migrate/20170526185842_migrate_pipeline_stages.rb new file mode 100644 index 00000000000..afd4db183c2 --- /dev/null +++ b/db/post_migrate/20170526185842_migrate_pipeline_stages.rb @@ -0,0 +1,22 @@ +class MigratePipelineStages < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + disable_statement_timeout + + execute <<-SQL.strip_heredoc + INSERT INTO ci_stages (project_id, pipeline_id, name) + SELECT project_id, commit_id, stage FROM ci_builds + WHERE stage IS NOT NULL + AND stage_id IS NULL + AND EXISTS (SELECT 1 FROM projects WHERE projects.id = ci_builds.project_id) + AND EXISTS (SELECT 1 FROM ci_pipelines WHERE ci_pipelines.id = ci_builds.commit_id) + GROUP BY project_id, commit_id, stage + ORDER BY MAX(stage_idx) + SQL + end +end diff --git a/db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb b/db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb new file mode 100644 index 00000000000..ec9ff33b6b7 --- /dev/null +++ b/db/post_migrate/20170526185858_create_index_in_pipeline_stages.rb @@ -0,0 +1,15 @@ +class CreateIndexInPipelineStages < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index(:ci_stages, [:pipeline_id, :name]) + end + + def down + remove_concurrent_index(:ci_stages, [:pipeline_id, :name]) + end +end diff --git a/db/post_migrate/20170526185921_migrate_build_stage_reference.rb b/db/post_migrate/20170526185921_migrate_build_stage_reference.rb new file mode 100644 index 00000000000..797e106cae4 --- /dev/null +++ b/db/post_migrate/20170526185921_migrate_build_stage_reference.rb @@ -0,0 +1,25 @@ +class MigrateBuildStageReference < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + disable_statement_timeout + + stage_id = Arel.sql <<-SQL.strip_heredoc + (SELECT id FROM ci_stages + WHERE ci_stages.pipeline_id = ci_builds.commit_id + AND ci_stages.name = ci_builds.stage) + SQL + + update_column_in_batches(:ci_builds, :stage_id, stage_id) do |table, query| + query.where(table[:stage_id].eq(nil)) + end + end + + def down + disable_statement_timeout + + update_column_in_batches(:ci_builds, :stage_id, nil) + end +end diff --git a/db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb b/db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb new file mode 100644 index 00000000000..15edb402b86 --- /dev/null +++ b/db/post_migrate/20170531203055_cleanup_users_ldap_email_rename.rb @@ -0,0 +1,15 @@ +class CleanupUsersLdapEmailRename < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + cleanup_concurrent_column_rename :users, :ldap_email, :external_email + end + + def down + rename_column_concurrently :users, :external_email, :ldap_email + end +end diff --git a/db/schema.rb b/db/schema.rb index 8c92543bb65..df1a8c8ef2d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,12 @@ # # It's strongly recommended that you check this file into your version control system. +<<<<<<< HEAD ActiveRecord::Schema.define(version: 20170606202615) do +======= +ActiveRecord::Schema.define(version: 20170603200744) do + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "pg_trgm" @@ -122,6 +127,7 @@ ActiveRecord::Schema.define(version: 20170606202615) do t.integer "cached_markdown_version" t.boolean "clientside_sentry_enabled", default: false, null: false t.string "clientside_sentry_dsn" + t.boolean "prometheus_metrics_enabled", default: false, null: false end create_table "audit_events", force: :cascade do |t| @@ -232,14 +238,20 @@ ActiveRecord::Schema.define(version: 20170606202615) do t.string "coverage_regex" t.integer "auto_canceled_by_id" t.boolean "retried" +<<<<<<< HEAD +======= + t.integer "stage_id" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end + add_index "ci_builds", ["auto_canceled_by_id"], name: "index_ci_builds_on_auto_canceled_by_id", using: :btree 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 add_index "ci_builds", ["commit_id", "status", "type"], name: "index_ci_builds_on_commit_id_and_status_and_type", using: :btree 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", ["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", ["stage_id"], name: "index_ci_builds_on_stage_id", using: :btree add_index "ci_builds", ["status", "type", "runner_id"], name: "index_ci_builds_on_status_and_type_and_runner_id", using: :btree add_index "ci_builds", ["status"], name: "index_ci_builds_on_status", using: :btree add_index "ci_builds", ["token"], name: "index_ci_builds_on_token", unique: true, using: :btree @@ -281,8 +293,10 @@ ActiveRecord::Schema.define(version: 20170606202615) do t.integer "lock_version" t.integer "auto_canceled_by_id" t.integer "pipeline_schedule_id" + t.integer "source" end + add_index "ci_pipelines", ["auto_canceled_by_id"], name: "index_ci_pipelines_on_auto_canceled_by_id", using: :btree add_index "ci_pipelines", ["pipeline_schedule_id"], name: "index_ci_pipelines_on_pipeline_schedule_id", using: :btree add_index "ci_pipelines", ["project_id", "ref", "status"], name: "index_ci_pipelines_on_project_id_and_ref_and_status", using: :btree add_index "ci_pipelines", ["project_id", "sha"], name: "index_ci_pipelines_on_project_id_and_sha", using: :btree @@ -322,6 +336,18 @@ ActiveRecord::Schema.define(version: 20170606202615) do 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 + create_table "ci_stages", force: :cascade do |t| + t.integer "project_id" + t.integer "pipeline_id" + t.datetime "created_at" + t.datetime "updated_at" + t.string "name" + end + + add_index "ci_stages", ["pipeline_id", "name"], name: "index_ci_stages_on_pipeline_id_and_name", using: :btree + add_index "ci_stages", ["pipeline_id"], name: "index_ci_stages_on_pipeline_id", using: :btree + add_index "ci_stages", ["project_id"], name: "index_ci_stages_on_project_id", using: :btree + create_table "ci_trigger_requests", force: :cascade do |t| t.integer "trigger_id", null: false t.text "variables" @@ -346,12 +372,13 @@ ActiveRecord::Schema.define(version: 20170606202615) do add_index "ci_triggers", ["project_id"], name: "index_ci_triggers_on_project_id", using: :btree create_table "ci_variables", force: :cascade do |t| - t.string "key" + t.string "key", null: false t.text "value" t.text "encrypted_value" t.string "encrypted_value_salt" t.string "encrypted_value_iv" - t.integer "project_id" + t.integer "project_id", null: false + t.boolean "protected", default: false, null: false end add_index "ci_variables", ["project_id"], name: "index_ci_variables_on_project_id", using: :btree @@ -366,6 +393,31 @@ ActiveRecord::Schema.define(version: 20170606202615) do add_index "container_repositories", ["project_id", "name"], name: "index_container_repositories_on_project_id_and_name", unique: true, using: :btree add_index "container_repositories", ["project_id"], name: "index_container_repositories_on_project_id", using: :btree + create_table "conversational_development_index_metrics", force: :cascade do |t| + t.float "leader_issues", null: false + t.float "instance_issues", null: false + t.float "leader_notes", null: false + t.float "instance_notes", null: false + t.float "leader_milestones", null: false + t.float "instance_milestones", null: false + t.float "leader_boards", null: false + t.float "instance_boards", null: false + t.float "leader_merge_requests", null: false + t.float "instance_merge_requests", null: false + t.float "leader_ci_pipelines", null: false + t.float "instance_ci_pipelines", null: false + t.float "leader_environments", null: false + t.float "instance_environments", null: false + t.float "leader_deployments", null: false + t.float "instance_deployments", null: false + t.float "leader_projects_prometheus_active", null: false + t.float "instance_projects_prometheus_active", null: false + t.float "leader_service_desk_issues", null: false + t.float "instance_service_desk_issues", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "deploy_keys_projects", force: :cascade do |t| t.integer "deploy_key_id", null: false t.integer "project_id", null: false @@ -437,6 +489,24 @@ ActiveRecord::Schema.define(version: 20170606202615) do add_index "events", ["target_id"], name: "index_events_on_target_id", using: :btree add_index "events", ["target_type"], name: "index_events_on_target_type", using: :btree + create_table "feature_gates", force: :cascade do |t| + t.string "feature_key", null: false + t.string "key", null: false + t.string "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "feature_gates", ["feature_key", "key", "value"], name: "index_feature_gates_on_feature_key_and_key_and_value", unique: true, using: :btree + + create_table "features", force: :cascade do |t| + t.string "key", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "features", ["key"], name: "index_features_on_key", unique: true, using: :btree + create_table "forked_project_links", force: :cascade do |t| t.integer "forked_to_project_id", null: false t.integer "forked_from_project_id", null: false @@ -690,6 +760,7 @@ ActiveRecord::Schema.define(version: 20170606202615) do t.integer "cached_markdown_version" t.datetime "last_edited_at" t.integer "last_edited_by_id" + t.integer "head_pipeline_id" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree @@ -790,6 +861,7 @@ ActiveRecord::Schema.define(version: 20170606202615) do t.string "discussion_id" t.text "note_html" t.integer "cached_markdown_version" + t.text "change_position" end add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree @@ -986,7 +1058,7 @@ ActiveRecord::Schema.define(version: 20170606202615) do t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" t.boolean "printing_merge_request_link_enabled", default: true, null: false - t.integer "auto_cancel_pending_pipelines", default: 0, null: false + t.integer "auto_cancel_pending_pipelines", default: 1, null: false t.string "import_jid" t.integer "cached_markdown_version" t.datetime "last_repository_updated_at" @@ -1123,13 +1195,13 @@ ActiveRecord::Schema.define(version: 20170606202615) do 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 t.boolean "commit_events", default: true, null: false + t.boolean "job_events", default: false, null: false end add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree @@ -1148,6 +1220,8 @@ ActiveRecord::Schema.define(version: 20170606202615) do t.text "title_html" t.text "content_html" t.integer "cached_markdown_version" + t.text "description" + t.text "description_html" end add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree @@ -1348,7 +1422,6 @@ ActiveRecord::Schema.define(version: 20170606202615) do t.boolean "hide_project_limit", default: false t.string "unlock_token" t.datetime "otp_grace_period_started_at" - t.boolean "ldap_email", default: false, null: false t.boolean "external", default: false t.string "incoming_email_token" t.string "organization" @@ -1358,6 +1431,9 @@ ActiveRecord::Schema.define(version: 20170606202615) do t.date "last_activity_on" t.boolean "notified_of_own_activity" t.string "preferred_language" + t.string "rss_token" + t.boolean "external_email", default: false, null: false + t.string "email_provider" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree @@ -1371,6 +1447,7 @@ ActiveRecord::Schema.define(version: 20170606202615) do add_index "users", ["name"], name: "index_users_on_name", using: :btree add_index "users", ["name"], name: "index_users_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree + add_index "users", ["rss_token"], name: "index_users_on_rss_token", using: :btree add_index "users", ["state"], name: "index_users_on_state", using: :btree add_index "users", ["username"], name: "index_users_on_username", using: :btree add_index "users", ["username"], name: "index_users_on_username_trigram", using: :gin, opclasses: {"username"=>"gin_trgm_ops"} @@ -1385,6 +1462,23 @@ ActiveRecord::Schema.define(version: 20170606202615) do add_index "users_star_projects", ["project_id"], name: "index_users_star_projects_on_project_id", using: :btree add_index "users_star_projects", ["user_id", "project_id"], name: "index_users_star_projects_on_user_id_and_project_id", unique: true, using: :btree + create_table "web_hook_logs", force: :cascade do |t| + t.integer "web_hook_id", null: false + t.string "trigger" + t.string "url" + t.text "request_headers" + t.text "request_data" + t.text "response_headers" + t.text "response_body" + t.string "response_status" + t.float "execution_duration" + t.string "internal_error_message" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "web_hook_logs", ["web_hook_id"], name: "index_web_hook_logs_on_web_hook_id", using: :btree + create_table "web_hooks", force: :cascade do |t| t.string "url", limit: 2000 t.integer "project_id" @@ -1398,11 +1492,12 @@ ActiveRecord::Schema.define(version: 20170606202615) do 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 + t.boolean "repository_update_events", default: false, null: false + t.boolean "job_events", default: false, null: false end add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree @@ -1411,11 +1506,16 @@ ActiveRecord::Schema.define(version: 20170606202615) do add_foreign_key "boards", "projects" add_foreign_key "chat_teams", "namespaces", on_delete: :cascade add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify + add_foreign_key "ci_builds", "ci_stages", column: "stage_id", name: "fk_3a9eaa254d", on_delete: :cascade add_foreign_key "ci_pipeline_schedules", "projects", name: "fk_8ead60fcc4", on_delete: :cascade + add_foreign_key "ci_pipeline_schedules", "users", column: "owner_id", name: "fk_9ea99f58d2", on_delete: :nullify add_foreign_key "ci_pipelines", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_3d34ab2e06", on_delete: :nullify add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify + add_foreign_key "ci_stages", "ci_pipelines", column: "pipeline_id", name: "fk_fb57e6cc56", on_delete: :cascade + add_foreign_key "ci_stages", "projects", name: "fk_2360681d1d", on_delete: :cascade add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade + add_foreign_key "ci_variables", "projects", name: "fk_ada5eb64b3", on_delete: :cascade add_foreign_key "container_repositories", "projects" add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade @@ -1445,4 +1545,5 @@ ActiveRecord::Schema.define(version: 20170606202615) do add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "u2f_registrations", "users" + add_foreign_key "web_hook_logs", "web_hooks", on_delete: :cascade end diff --git a/doc/README.md b/doc/README.md index 7bab42bc135..9f12eed1471 100644 --- a/doc/README.md +++ b/doc/README.md @@ -42,6 +42,9 @@ Shortcuts to GitLab's most visited docs: - [Create a group](gitlab-basics/create-group.md) - [GitLab Subgroups](user/group/subgroups/index.md) - [Search through GitLab](user/search/index.md): Search for issues, merge requests, projects, groups, todos, and issues in Issue Boards. +- [Snippets](user/snippets.md): Snippets allow you to create little bits of code. +- [Wikis](user/project/wiki/index.md): Enhance your repository documentation with built-in wikis. +- [GitLab Pages](user/project/pages/index.md): Build, test, and deploy your static website with GitLab Pages. ### Repository @@ -83,14 +86,6 @@ Manage files and branches from the UI (user interface): - [Importing to GitLab](workflow/importing/README.md): Import your projects from GitHub, Bitbucket, GitLab.com, FogBugz and SVN into GitLab. - [Migrating from SVN](workflow/importing/migrating_from_svn.md): Convert a SVN repository to Git and GitLab. -## GitLab's superpowers - -Take a step ahead and dive into GitLab's advanced features. - -- [GitLab Pages](user/project/pages/index.md): Build, test, and deploy your static website with GitLab Pages. -- [Snippets](user/snippets.md): Snippets allow you to create little bits of code. -- [Wikis](user/project/wiki/index.md): Enhance your repository documentation with built-in wikis. - ### Continuous Integration, Delivery, and Deployment - [GitLab CI](ci/README.md): Explore the features and capabilities of Continuous Integration, Continuous Delivery, and Continuous Deployment with GitLab. diff --git a/doc/administration/container_registry.md b/doc/administration/container_registry.md index f707039827b..afafb6bf1f5 100644 --- a/doc/administration/container_registry.md +++ b/doc/administration/container_registry.md @@ -1,10 +1,7 @@ # GitLab Container Registry administration -> [Introduced][ce-4040] in GitLab 8.8. - ---- - > **Notes:** +- [Introduced][ce-4040] in GitLab 8.8. - 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 @@ -514,8 +511,8 @@ configurable in future releases. ## Configure Container Registry notifications -You can configure the Container Registry to send webhook notifications in -response to events happening within the registry. +You can configure the Container Registry to send webhook notifications in +response to events happening within the registry. Read more about the Container Registry notifications config options in the [Docker Registry notifications documentation][notifications-config]. @@ -568,12 +565,25 @@ notifications: backoff: 1000 ``` -## Changelog +## Using self-signed certificates with Container Registry + +If you're using a self-signed certificate with your Container Registry, you +might encounter issues during the CI jobs like the following: + +``` +Error response from daemon: Get registry.example.com/v1/users/: x509: certificate signed by unknown authority +``` -**GitLab 8.8 ([source docs][8-8-docs])** +The Docker daemon running the command expects a cert signed by a recognized CA, +thus the error above. -- GitLab Container Registry feature was introduced. +While GitLab doesn't support using self-signed certificates with Container +Registry out of the box, it is possible to make it work if you follow +[Docker's documentation][docker-insecure]. You may find some additional +information in [issue 18239][ce-18239]. +[ce-18239]: https://gitlab.com/gitlab-org/gitlab-ce/issues/18239 +[docker-insecure]: https://docs.docker.com/registry/insecure/#using-self-signed-certificates [reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure [restart gitlab]: restart_gitlab.md#installations-from-source [wildcard certificate]: https://en.wikipedia.org/wiki/Wildcard_certificate @@ -589,4 +599,4 @@ notifications: [existing-domain]: #configure-container-registry-under-an-existing-gitlab-domain [new-domain]: #configure-container-registry-under-its-own-domain [notifications-config]: https://docs.docker.com/registry/notifications/ -[registry-notifications-config]: https://docs.docker.com/registry/configuration/#notifications
\ No newline at end of file +[registry-notifications-config]: https://docs.docker.com/registry/configuration/#notifications diff --git a/doc/administration/environment_variables.md b/doc/administration/environment_variables.md index 76029b30dd8..b6676026d06 100644 --- a/doc/administration/environment_variables.md +++ b/doc/administration/environment_variables.md @@ -20,7 +20,6 @@ Variable | Type | Description `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 diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md index 6c6942a7bfe..48929910a9c 100644 --- a/doc/administration/gitaly/index.md +++ b/doc/administration/gitaly/index.md @@ -2,7 +2,7 @@ [Gitaly](https://gitlab.com/gitlab-org/gitaly) (introduced in GitLab 9.0) is a service that provides high-level RPC access to Git -repositories. As of GitLab 9.1 it is still an optional component with +repositories. As of GitLab 9.3 it is still an optional component with limited scope. GitLab components that access Git repositories (gitlab-rails, @@ -35,7 +35,7 @@ gitlab restart`. ## Configuring GitLab to not use Gitaly -Gitaly is still an optional component in GitLab 9.0. This means you +Gitaly is still an optional component in GitLab 9.3. This means you can choose to not use it. In Omnibus you can make the following change in diff --git a/doc/administration/high_availability/README.md b/doc/administration/high_availability/README.md index d5a5aef7ec0..4d3be0ab8f6 100644 --- a/doc/administration/high_availability/README.md +++ b/doc/administration/high_availability/README.md @@ -5,6 +5,20 @@ The solution you choose will be based on the level of scalability and availability you require. The easiest solutions are scalable, but not necessarily highly available. +GitLab provides a service that is usually essential to most organizations: it +enables people to collaborate on code in a timely fashion. Any downtime should +therefore be short and planned. Luckily, GitLab provides a solid setup even on +a single server without special measures. Due to the distributed nature +of Git, developers can still commit code locally even when GitLab is not +available. However, some GitLab features such as the issue tracker and +Continuous Integration are not available when GitLab is down. + +**Keep in mind that all Highly Available solutions come with a trade-off between +cost/complexity and uptime**. The more uptime you want, the more complex the +solution. And the more complex the solution, the more work is involved in +setting up and maintaining it. High availability is not free and every HA +solution should balance the costs against the benefits. + ## Architecture There are two kinds of setups: @@ -37,6 +51,10 @@ Block Device) to keep all data in sync. DRBD requires a low latency link to remain in sync. It is not advisable to attempt to run DRBD between data centers or in different cloud availability zones. +> **Note:** GitLab recommends against choosing this HA method because of the + complexity of managing DRBD and crafting automatic failover. This is + *compatible* with GitLab, but not officially *supported*. + Components/Servers Required: 2 servers/virtual machines (one active/one passive)  diff --git a/doc/administration/high_availability/database.md b/doc/administration/high_availability/database.md index c22b1af8bfb..da9687aa849 100644 --- a/doc/administration/high_availability/database.md +++ b/doc/administration/high_availability/database.md @@ -27,7 +27,7 @@ If you use a cloud-managed service, or provide your own PostgreSQL: 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. If there is a directive listed below that you do not see in the configuration, be sure to add it. ```ruby external_url 'https://gitlab.example.com' @@ -39,6 +39,8 @@ If you use a cloud-managed service, or provide your own PostgreSQL: unicorn['enable'] = false sidekiq['enable'] = false redis['enable'] = false + prometheus['enable'] = false + gitaly['enable'] = false gitlab_workhorse['enable'] = false mailroom['enable'] = false diff --git a/doc/administration/high_availability/nfs.md b/doc/administration/high_availability/nfs.md index c5125dc6d5a..d8e76d6ab94 100644 --- a/doc/administration/high_availability/nfs.md +++ b/doc/administration/high_availability/nfs.md @@ -7,6 +7,25 @@ supported natively in NFS version 4. NFSv3 also supports locking as long as Linux Kernel 2.6.5+ is used. We recommend using version 4 and do not specifically test NFSv3. +## AWS Elastic File System + +GitLab does not recommend using AWS Elastic File System (EFS). + +Customers and users have reported that AWS EFS does not perform well for GitLab's +use-case. There are several issues that can cause problems. For these reasons +GitLab does not recommend using EFS with GitLab. + +- EFS bases allowed IOPS on volume size. The larger the volume, the more IOPS + are allocated. For smaller volumes, users may experience decent performance + for a period of time due to 'Burst Credits'. Over a period of weeks to months + credits may run out and performance will bottom out. +- For larger volumes, allocated IOPS may not be the problem. Workloads where + many small files are written in a serialized manner are not well-suited for EFS. + EBS with an NFS server on top will perform much better. + +For more details on another person's experience with EFS, see +[Amazon's Elastic File System: Burst Credits](https://www.rawkode.io/2017/04/amazons-elastic-file-system-burst-credits/) + ### Recommended options When you define your NFS exports, we recommend you also add the following diff --git a/doc/administration/high_availability/redis.md b/doc/administration/high_availability/redis.md index 4638a9c9782..0e92f7c5a34 100644 --- a/doc/administration/high_availability/redis.md +++ b/doc/administration/high_availability/redis.md @@ -42,10 +42,10 @@ instances run in different machines. If you fail to provision the machines in that specific way, any issue with the shared environment can bring your entire setup down. -It is OK to run a Sentinel along with a master or slave Redis instance. -No more than one Sentinel in the same machine though. +It is OK to run a Sentinel alongside of a master or slave Redis instance. +There should be no more than one Sentinel on the same machine though. -You also need to take in consideration the underlying network topology, +You also need to take into consideration the underlying network topology, making sure you have redundant connectivity between Redis / Sentinel and GitLab instances, otherwise the networks will become a single point of failure. @@ -113,7 +113,7 @@ the Omnibus GitLab package in `5` **independent** machines, both with ### Redis setup overview You must have at least `3` Redis servers: `1` Master, `2` Slaves, and they -need to be each in a independent machine (see explanation above). +need to each be on independent machines (see explanation above). You can have additional Redis nodes, that will help survive a situation where more nodes goes down. Whenever there is only `2` nodes online, a failover @@ -232,7 +232,7 @@ Pick the one that suits your needs. This is the section where we install and setup the new Redis instances. >**Notes:** -- We assume that you install GitLab and all HA components from scratch. If you +- We assume that you have installed GitLab and all HA components from scratch. If you already have it installed and running, read how to [switch from a single-machine installation to Redis HA](#switching-from-an-existing-single-machine-installation-to-redis-ha). - Redis nodes (both master and slaves) will need the same password defined in @@ -245,10 +245,9 @@ The prerequisites for a HA Redis setup are the following: 1. Provision the minimum required number of instances as specified in the [recommended setup](#recommended-setup) section. -1. **Do NOT** install Redis or Redis Sentinel in the same machines your - GitLab application is running on. You can however opt in to install Redis - and Sentinel in the same machine (each in independent ones is recommended - though). +1. We **Do not** recommend installing Redis or Redis Sentinel in the same machines your + GitLab application is running on as this weakens your HA configuration. You can however opt in to install Redis + and Sentinel in the same machine. 1. All Redis nodes must be able to talk to each other and accept incoming connections over Redis (`6379`) and Sentinel (`26379`) ports (unless you change the default ones). diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md index 7b0610ae414..5599435564e 100644 --- a/doc/administration/job_artifacts.md +++ b/doc/administration/job_artifacts.md @@ -82,6 +82,42 @@ _The artifacts are stored by default in 1. Save the file and [restart GitLab][] for the changes to take effect. +## Expiring artifacts + +If an expiry date is used for the artifacts, they are marked for deletion +right after that date passes. Artifacts are cleaned up by the +`expire_build_artifacts_worker` cron job which is run by Sidekiq every hour at +50 minutes (`50 * * * *`). + +To change the default schedule on which the artifacts are expired, follow the +steps below. + +--- + +**In Omnibus installations:** + +1. Edit `/etc/gitlab/gitlab.rb` and comment out or add the following line + + ```ruby + gitlab_rails['expire_build_artifacts_worker_cron'] = "50 * * * *" + ``` + +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 + expire_build_artifacts_worker: + cron: "50 * * * *" + ``` + +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 diff --git a/doc/api/README.md b/doc/api/README.md index d444ce94573..e1d4009dedc 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -15,6 +15,8 @@ following locations: - [Commits](commits.md) - [Deployments](deployments.md) - [Deploy Keys](deploy_keys.md) +- [Environments](environments.md) +- [Events](events.md) - [Gitignores templates](templates/gitignores.md) - [GitLab CI Config templates](templates/gitlab_ci_ymls.md) - [Groups](groups.md) @@ -33,6 +35,7 @@ following locations: - [Notification settings](notification_settings.md) - [Pipelines](pipelines.md) - [Pipeline Triggers](pipeline_triggers.md) +- [Pipeline Schedules](pipeline_schedules.md) - [Projects](projects.md) including setting Webhooks - [Project Access Requests](access_requests.md) - [Project Members](members.md) @@ -61,8 +64,9 @@ The following documentation is for the [internal CI API](ci/README.md): ## Authentication -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 +Most API requests require authentication via a session cookie or token. For those cases where it is not required, this will be mentioned in the documentation +for each individual endpoint. For example, the [`/projects/:id` endpoint](projects.md). +There are three types of tokens available: private tokens, OAuth 2 tokens, and personal access tokens. If authentication information is invalid or omitted, an error message will be diff --git a/doc/api/access_requests.md b/doc/api/access_requests.md index 21de7d18632..603fa4a8194 100644 --- a/doc/api/access_requests.md +++ b/doc/api/access_requests.md @@ -1,4 +1,4 @@ -# Group and project access requests +# Group and project access requests API >**Note:** This feature was introduced in GitLab 8.11 diff --git a/doc/api/award_emoji.md b/doc/api/award_emoji.md index 5f3adcc397a..d6924741ee4 100644 --- a/doc/api/award_emoji.md +++ b/doc/api/award_emoji.md @@ -1,4 +1,4 @@ -# Award Emoji +# Award Emoji API > [Introduced][ce-4575] in GitLab 8.9, Snippet support in 8.12 diff --git a/doc/api/boards.md b/doc/api/boards.md index 17d2be0ee16..69c47abc806 100644 --- a/doc/api/boards.md +++ b/doc/api/boards.md @@ -1,4 +1,4 @@ -# Boards +# Issue Boards API Every API call to boards must be authenticated. diff --git a/doc/api/branches.md b/doc/api/branches.md index 5717215deb6..325d0ea4ce3 100644 --- a/doc/api/branches.md +++ b/doc/api/branches.md @@ -1,4 +1,4 @@ -# Branches +# Branches API ## List repository branches diff --git a/doc/api/broadcast_messages.md b/doc/api/broadcast_messages.md index ad254e3515e..a8a248a17f4 100644 --- a/doc/api/broadcast_messages.md +++ b/doc/api/broadcast_messages.md @@ -1,4 +1,4 @@ -# Broadcast Messages +# Broadcast Messages API > **Note:** This feature was introduced in GitLab 8.12. diff --git a/doc/api/build_variables.md b/doc/api/build_variables.md index 9218902e84a..d4f00256ed3 100644 --- a/doc/api/build_variables.md +++ b/doc/api/build_variables.md @@ -1,4 +1,4 @@ -# Build Variables +# Build Variables API ## List project variables @@ -61,11 +61,12 @@ Create a new build variable. POST /projects/:id/variables ``` -| Attribute | Type | required | Description | -|-----------|---------|----------|-----------------------| -| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed | -| `value` | string | yes | The `value` of a variable | +| Attribute | Type | required | Description | +|-------------|---------|----------|-----------------------| +| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed | +| `value` | string | yes | The `value` of a variable | +| `protected` | boolean | no | Whether the variable is protected | ``` curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables" --form "key=NEW_VARIABLE" --form "value=new value" @@ -74,7 +75,8 @@ curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitl ```json { "key": "NEW_VARIABLE", - "value": "new value" + "value": "new value", + "protected": false } ``` @@ -86,11 +88,12 @@ Update a project's build variable. PUT /projects/:id/variables/:key ``` -| Attribute | Type | required | Description | -|-----------|---------|----------|-------------------------| -| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `key` | string | yes | The `key` of a variable | -| `value` | string | yes | The `value` of a variable | +| Attribute | Type | required | Description | +|-------------|---------|----------|-------------------------| +| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `key` | string | yes | The `key` of a variable | +| `value` | string | yes | The `value` of a variable | +| `protected` | boolean | no | Whether the variable is protected | ``` curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables/NEW_VARIABLE" --form "value=updated value" @@ -99,7 +102,8 @@ curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitla ```json { "key": "NEW_VARIABLE", - "value": "updated value" + "value": "updated value", + "protected": true } ``` diff --git a/doc/api/ci/lint.md b/doc/api/ci/lint.md index 74def207816..6a4dca92cfe 100644 --- a/doc/api/ci/lint.md +++ b/doc/api/ci/lint.md @@ -1,4 +1,4 @@ -# Validate the .gitlab-ci.yml +# Validate the .gitlab-ci.yml (API) > [Introduced][ce-5953] in GitLab 8.12. diff --git a/doc/api/ci/runners.md b/doc/api/ci/runners.md index 16028d1f124..342c039dad8 100644 --- a/doc/api/ci/runners.md +++ b/doc/api/ci/runners.md @@ -1,4 +1,4 @@ -# Runners API +# Register and Delete Runners API API used by Runners to register and delete themselves. diff --git a/doc/api/deploy_key_multiple_projects.md b/doc/api/deploy_key_multiple_projects.md index f94dbfa4059..127f9a196de 100644 --- a/doc/api/deploy_key_multiple_projects.md +++ b/doc/api/deploy_key_multiple_projects.md @@ -1,4 +1,4 @@ -# Adding deploy keys to multiple projects +# Adding deploy keys to multiple projects via API If you want to easily add the same deploy key to multiple projects in the same group, this can be achieved quite easily with the API. diff --git a/doc/api/deploy_keys.md b/doc/api/deploy_keys.md index c3fe7f84ef2..4fa800ecb9c 100644 --- a/doc/api/deploy_keys.md +++ b/doc/api/deploy_keys.md @@ -1,4 +1,4 @@ -# Deploy Keys +# Deploy Keys API ## List all deploy keys diff --git a/doc/api/enviroments.md b/doc/api/environments.md index 49930f01945..5ca766bf87d 100644 --- a/doc/api/enviroments.md +++ b/doc/api/environments.md @@ -1,4 +1,4 @@ -# Environments +# Environments API ## List environments diff --git a/doc/api/events.md b/doc/api/events.md new file mode 100644 index 00000000000..e7829c9f479 --- /dev/null +++ b/doc/api/events.md @@ -0,0 +1,347 @@ +# Events + +## Filter parameters + +### Action Types + +Available action types for the `action` parameter are: + +- `created` +- `updated` +- `closed` +- `reopened` +- `pushed` +- `commented` +- `merged` +- `joined` +- `left` +- `destroyed` +- `expired` + +Note that these options are downcased. + +### Target Types + +Available target types for the `target_type` parameter are: + +- `issue` +- `milestone` +- `merge_request` +- `note` +- `project` +- `snippet` +- `user` + +Note that these options are downcased. + +### Date formatting + +Dates for the `before` and `after` parameters should be supplied in the following format: + +``` +YYYY-MM-DD +``` + +## List currently authenticated user's events + +>**Note:** This endpoint was introduced in GitLab 9.3. + +Get a list of events for the authenticated user. + +``` +GET /events +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `action` | string | no | Include only events of a particular [action type][action-types] | +| `target_type` | string | no | Include only events of a particular [target type][target-types] | +| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | +| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] | +| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` | + +Example request: + +``` +curl --header "PRIVATE-TOKEN 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/events&target_type=issue&action=created&after=2017-01-31&before=2017-03-01 +``` + +Example response: + +```json +[ + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":160, + "target_type":"Issue", + "author_id":25, + "data":null, + "target_title":"Qui natus eos odio tempore et quaerat consequuntur ducimus cupiditate quis.", + "created_at":"2017-02-09T10:43:19.667Z", + "author":{ + "name":"User 3", + "username":"user3", + "id":25, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/97d6d9441ff85fdc730e02a6068d267b?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/user3" + }, + "author_username":"user3" + }, + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":159, + "target_type":"Issue", + "author_id":21, + "data":null, + "target_title":"Nostrum enim non et sed optio illo deleniti non.", + "created_at":"2017-02-09T10:43:19.426Z", + "author":{ + "name":"Test User", + "username":"ted", + "id":21, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/80fb888c9a48b9a3f87477214acaa63f?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/ted" + }, + "author_username":"ted" + } +] +``` + +### Get user contribution events + +>**Note:** Documentation was formerly located in the [Users API pages][users-api]. + +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 or Username of the user | +| `action` | string | no | Include only events of a particular [action type][action-types] | +| `target_type` | string | no | Include only events of a particular [target type][target-types] | +| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | +| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] | +| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/: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/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/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/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/root" + }, + "created_at": "2015-12-04T10:33:56.698Z", + "system": 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/root" + }, + "author_username": "root" + } +] +``` + +## List a Project's visible events + +>**Note:** This endpoint has been around longer than the others. Documentation was formerly located in the [Projects API pages][projects-api]. + +Get a list of visible events for a particular project. + +``` +GET /:project_id/events +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `project_id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `action` | string | no | Include only events of a particular [action type][action-types] | +| `target_type` | string | no | Include only events of a particular [target type][target-types] | +| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | +| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] | +| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` | + +Example request: + +``` +curl --header "PRIVATE-TOKEN 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:project_id/events&target_type=issue&action=created&after=2017-01-31&before=2017-03-01 +``` + +Example response: + +```json +[ + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":160, + "target_type":"Issue", + "author_id":25, + "data":null, + "target_title":"Qui natus eos odio tempore et quaerat consequuntur ducimus cupiditate quis.", + "created_at":"2017-02-09T10:43:19.667Z", + "author":{ + "name":"User 3", + "username":"user3", + "id":25, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/97d6d9441ff85fdc730e02a6068d267b?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/user3" + }, + "author_username":"user3" + }, + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":159, + "target_type":"Issue", + "author_id":21, + "data":null, + "target_title":"Nostrum enim non et sed optio illo deleniti non.", + "created_at":"2017-02-09T10:43:19.426Z", + "author":{ + "name":"Test User", + "username":"ted", + "id":21, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/80fb888c9a48b9a3f87477214acaa63f?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/ted" + }, + "author_username":"ted" + } +] +``` + +[target-types]: #target-types "Target Type parameter" +[action-types]: #action-types "Action Type parameter" +[date-formatting]: #date-formatting "Date Formatting guidance" +[projects-api]: projects.md "Projects API pages" +[users-api]: users.md "Users API pages" diff --git a/doc/api/features.md b/doc/api/features.md new file mode 100644 index 00000000000..89b8d3ac948 --- /dev/null +++ b/doc/api/features.md @@ -0,0 +1,83 @@ +# Features API + +All methods require administrator authorization. + +Notice that currently the API only supports boolean and percentage-of-time gate +values. + +## List all features + +Get a list of all persisted features, with its gate values. + +``` +GET /features +``` + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features +``` + +Example response: + +```json +[ + { + "name": "experimental_feature", + "state": "off", + "gates": [ + { + "key": "boolean", + "value": false + } + ] + }, + { + "name": "new_library", + "state": "on", + "gates": [ + { + "key": "boolean", + "value": true + } + ] + } +] +``` + +## Set or create a feature + +Set a feature's gate value. If a feature with the given name doesn't exist yet +it will be created. The value can be a boolean, or an integer to indicate +percentage of time. + +``` +POST /features/:name +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | yes | Name of the feature to create or update | +| `value` | integer/string | yes | `true` or `false` to enable/disable, or an integer for percentage of time | + +```bash +curl --data "value=30" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features/new_library +``` + +Example response: + +```json +{ + "name": "new_library", + "state": "conditional", + "gates": [ + { + "key": "boolean", + "value": false + }, + { + "key": "percentage_of_time", + "value": 30 + } + ] +} +``` diff --git a/doc/api/groups.md b/doc/api/groups.md index bc61bfec9b9..2b3d8e125c8 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -1,4 +1,4 @@ -# Groups +# Groups API ## List groups diff --git a/doc/api/issues.md b/doc/api/issues.md index 9798a845e6f..3f949ca5667 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -1,4 +1,4 @@ -# Issues +# Issues API Every API call to issues must be authenticated. diff --git a/doc/api/jobs.md b/doc/api/jobs.md index 404da3dc603..297115e94ac 100644 --- a/doc/api/jobs.md +++ b/doc/api/jobs.md @@ -11,7 +11,7 @@ GET /projects/:id/jobs | Attribute | Type | Required | Description | |-----------|---------|----------|---------------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`; showing all jobs if none provided | +| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`, `manual`; showing all jobs if none provided | ``` curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/1/jobs?scope[]=pending&scope[]=running' @@ -125,7 +125,7 @@ GET /projects/:id/pipelines/:pipeline_id/jobs |---------------|--------------------------------|----------|----------------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `pipeline_id` | integer | yes | The ID of a pipeline | -| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`; showing all jobs if none provided | +| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`, `manual`; showing all jobs if none provided | ``` curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/1/pipelines/6/jobs?scope[]=pending&scope[]=running' diff --git a/doc/api/keys.md b/doc/api/keys.md index 3ace1040f38..376ac27df3a 100644 --- a/doc/api/keys.md +++ b/doc/api/keys.md @@ -1,4 +1,4 @@ -# Keys +# Keys API ## Get SSH key with user by ID of an SSH key diff --git a/doc/api/labels.md b/doc/api/labels.md index 778348ea371..ec93cf50e7a 100644 --- a/doc/api/labels.md +++ b/doc/api/labels.md @@ -1,4 +1,4 @@ -# Labels +# Labels API ## List labels diff --git a/doc/api/members.md b/doc/api/members.md index 3c661284f11..3234f833eae 100644 --- a/doc/api/members.md +++ b/doc/api/members.md @@ -1,4 +1,4 @@ -# Group and project members +# Group and project members API **Valid access levels** diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index dde855b2bd4..cb22b67f556 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -1,4 +1,4 @@ -# Merge requests +# Merge requests API ## List merge requests diff --git a/doc/api/milestones.md b/doc/api/milestones.md index 7640eeb8d00..a082d548499 100644 --- a/doc/api/milestones.md +++ b/doc/api/milestones.md @@ -1,4 +1,4 @@ -# Milestones +# Milestones API ## List project milestones diff --git a/doc/api/namespaces.md b/doc/api/namespaces.md index eef06d5f324..4ad6071a0ed 100644 --- a/doc/api/namespaces.md +++ b/doc/api/namespaces.md @@ -1,4 +1,4 @@ -# Namespaces +# Namespaces API Usernames and groupnames fall under a special category called namespaces. diff --git a/doc/api/notes.md b/doc/api/notes.md index b71fea5fc9f..388e6989df2 100644 --- a/doc/api/notes.md +++ b/doc/api/notes.md @@ -1,4 +1,4 @@ -# Notes +# Notes API Notes are comments on snippets, issues or merge requests. diff --git a/doc/api/notification_settings.md b/doc/api/notification_settings.md index 43047917f77..3a2c398e355 100644 --- a/doc/api/notification_settings.md +++ b/doc/api/notification_settings.md @@ -1,4 +1,4 @@ -# Notification settings +# Notification settings API >**Note:** This feature was [introduced][ce-5632] in GitLab 8.12. diff --git a/doc/api/pipeline_schedules.md b/doc/api/pipeline_schedules.md new file mode 100644 index 00000000000..433654c18cc --- /dev/null +++ b/doc/api/pipeline_schedules.md @@ -0,0 +1,273 @@ +# Pipeline schedules + +You can read more about [pipeline schedules](../user/project/pipelines/schedules.md). + +## Get all pipeline schedules + +Get a list of the pipeline schedules of a project. + +``` +GET /projects/:id/pipeline_schedules +``` + +| Attribute | Type | required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `scope` | string | no | The scope of pipeline schedules, one of: `active`, `inactive` | + +```sh +curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules" +``` + +```json +[ + { + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "* * * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T13:41:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:40:17.727Z", + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } + } +] +``` + +## Get a single pipeline schedule + +Get the pipeline schedule of a project. + +``` +GET /projects/:id/pipeline_schedules/:pipeline_schedule_id +``` + +| Attribute | Type | required | Description | +|--------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | + +```sh +curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "* * * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T13:41:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:40:17.727Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } +} +``` + +## Create a new pipeline schedule + +Create a new pipeline schedule of a project. + +``` +POST /projects/:id/pipeline_schedules +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `description` | string | yes | The description of pipeline schedule | +| `ref` | string | yes | The branch/tag name will be triggered | +| `cron ` | string | yes | The cron (e.g. `0 1 * * *`) ([Cron syntax](https://en.wikipedia.org/wiki/Cron)) | +| `cron_timezone ` | string | no | The timezone supproted by `ActiveSupport::TimeZone` (e.g. `Pacific Time (US & Canada)`) (default: `'UTC'`) | +| `active ` | boolean | no | The activation of pipeline schedule. If false is set, the pipeline schedule will deactivated initially (default: `true`) | + +```sh +curl --request POST --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form description="Build packages" --form ref="master" --form cron="0 1 * * 5" --form cron_timezone="UTC" --form active="true" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules" +``` + +```json +{ + "id": 14, + "description": "Build packages", + "ref": "master", + "cron": "0 1 * * 5", + "cron_timezone": "UTC", + "next_run_at": "2017-05-26T01:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:43:08.169Z", + "updated_at": "2017-05-19T13:43:08.169Z", + "last_pipeline": null, + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } +} +``` + +## Edit a pipeline schedule + +Updates the pipeline schedule of a project. Once the update is done, it will be rescheduled automatically. + +``` +PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | +| `description` | string | no | The description of pipeline schedule | +| `ref` | string | no | The branch/tag name will be triggered | +| `cron ` | string | no | The cron (e.g. `0 1 * * *`) ([Cron syntax](https://en.wikipedia.org/wiki/Cron)) | +| `cron_timezone ` | string | no | The timezone supproted by `ActiveSupport::TimeZone` (e.g. `Pacific Time (US & Canada)`) or `TZInfo::Timezone` (e.g. `America/Los_Angeles`) | +| `active ` | boolean | no | The activation of pipeline schedule. If false is set, the pipeline schedule will deactivated initially. | + +```sh +curl --request PUT --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form cron="0 2 * * *" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "0 2 * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T17:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:44:16.135Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } +} +``` + +## Take ownership of a pipeline schedule + +Update the owner of the pipeline schedule of a project. + +``` +POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/take_ownership +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | + +```sh +curl --request POST --header "PRIVATE-TOKEN: hf2CvZXB9w8Uc5pZKpSB" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/take_ownership" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "0 2 * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T17:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:46:37.468Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "shinya", + "username": "maeda", + "id": 50, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/8ca0a796a679c292e3a11da50f99e801?s=80&d=identicon", + "web_url": "https://gitlab.example.com/maeda" + } +} +``` + +## Delete a pipeline schedule + +Delete the pipeline schedule of a project. + +``` +DELETE /projects/:id/pipeline_schedules/:pipeline_schedule_id +``` + +| Attribute | Type | required | Description | +|----------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | + +```sh +curl --request DELETE --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "0 2 * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T17:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:46:37.468Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "shinya", + "username": "maeda", + "id": 50, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/8ca0a796a679c292e3a11da50f99e801?s=80&d=identicon", + "web_url": "https://gitlab.example.com/maeda" + } +} +``` diff --git a/doc/api/pipeline_triggers.md b/doc/api/pipeline_triggers.md index d639e8a0991..9030ae32d17 100644 --- a/doc/api/pipeline_triggers.md +++ b/doc/api/pipeline_triggers.md @@ -1,4 +1,4 @@ -# Pipeline triggers +# Pipeline triggers API You can read more about [triggering pipelines through the API](../ci/triggers/README.md). diff --git a/doc/api/project_snippets.md b/doc/api/project_snippets.md index ff379473961..92491de4daa 100644 --- a/doc/api/project_snippets.md +++ b/doc/api/project_snippets.md @@ -43,6 +43,7 @@ Parameters: "id": 1, "title": "test", "file_name": "add.rb", + "description": "Ruby test snippet", "author": { "id": 1, "username": "john_smith", @@ -70,6 +71,7 @@ Parameters: - `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user - `title` (required) - The title of a snippet - `file_name` (required) - The name of a snippet file +- `description` (optional) - The description of a snippet - `code` (required) - The content of a snippet - `visibility` (required) - The snippet's visibility @@ -87,6 +89,7 @@ Parameters: - `snippet_id` (required) - The ID of a project's snippet - `title` (optional) - The title of a snippet - `file_name` (optional) - The name of a snippet file +- `description` (optional) - The description of a snippet - `code` (optional) - The content of a snippet - `visibility` (optional) - The snippet's visibility diff --git a/doc/api/projects.md b/doc/api/projects.md index 188fbe7447d..0debdcfae89 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1,5 +1,4 @@ -# Projects - +# Projects API ### Project visibility level @@ -17,8 +16,6 @@ Constants for project visibility levels are next: * `public`: The project can be cloned without any authentication. - - ## List projects Get a list of visible projects for authenticated user. When being accessed without authentication, all public projects are returned. @@ -41,6 +38,8 @@ Parameters: | `membership` | boolean | no | Limit by projects that the current user is a member of | | `starred` | boolean | no | Limit by projects starred by the current user | | `statistics` | boolean | no | Include project statistics | +| `with_issues_enabled` | boolean | no | Limit by enabled issues feature | +| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature | ```json [ @@ -82,6 +81,7 @@ Parameters: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", "archived": false, "avatar_url": "http://example.com/uploads/project/avatar/4/uploads/avatar.png", "shared_runners_enabled": true, @@ -139,6 +139,8 @@ Parameters: "kind": "group", "full_path": "brightbox" }, + "import_status": "none", + "import_error": null, "permissions": { "project_access": { "access_level": 10, @@ -226,6 +228,8 @@ Parameters: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", + "import_error": null, "permissions": { "project_access": { "access_level": 10, @@ -306,143 +310,7 @@ GET /projects/:id/users ### Get project events -Get the events for the specified project sorted from newest to oldest. This -endpoint can be accessed without authentication if the project is publicly -accessible. - -``` -GET /projects/:id/events -``` - -Parameters: - -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | - -```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/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/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/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/root" - }, - "created_at": "2015-12-04T10:33:56.698Z", - "system": 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/root" - }, - "author_username": "root" - } -] -``` +Please refer to the [Events API documentation](events.md#list-a-projects-visible-events) ### Create project @@ -474,6 +342,7 @@ Parameters: | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | +| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | ### Create project for user @@ -507,6 +376,7 @@ Parameters: | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | +| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | ### Edit project @@ -539,6 +409,7 @@ Parameters: | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | +| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | ### Fork project @@ -609,6 +480,7 @@ Example response: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", "archived": true, "avatar_url": "http://example.com/uploads/project/avatar/3/uploads/avatar.png", "shared_runners_enabled": true, @@ -674,6 +546,7 @@ Example response: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", "archived": true, "avatar_url": "http://example.com/uploads/project/avatar/3/uploads/avatar.png", "shared_runners_enabled": true, @@ -745,6 +618,8 @@ Example response: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", + "import_error": null, "permissions": { "project_access": { "access_level": 10, @@ -827,6 +702,8 @@ Example response: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", + "import_error": null, "permissions": { "project_access": { "access_level": 10, diff --git a/doc/api/repositories.md b/doc/api/repositories.md index 859cbd63831..bccef924375 100644 --- a/doc/api/repositories.md +++ b/doc/api/repositories.md @@ -1,4 +1,4 @@ -# Repositories +# Repositories API ## List repository tree diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md index aec91abd390..18ceb8f779e 100644 --- a/doc/api/repository_files.md +++ b/doc/api/repository_files.md @@ -1,4 +1,4 @@ -# Repository files +# Repository files API **CRUD for repository files** @@ -111,6 +111,7 @@ Parameters: - `author_name` (optional) - Specify the commit author's name - `content` (required) - New file content - `commit_message` (required) - Commit message +- `last_commit_id` (optional) - Last known file commit id If the commit fails for any reason we return a 400 error with a non-specific error message. Possible causes for a failed commit include: diff --git a/doc/api/services.md b/doc/api/services.md index 0f42c256099..49b87a4228c 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -1,4 +1,4 @@ -# Services +# Services API ## Asana @@ -516,7 +516,7 @@ Example response: "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "pipeline_events": true, "properties": { "token": "9koXpg98eAheJpvBs5tK" diff --git a/doc/api/session.md b/doc/api/session.md index 056cc32597c..7dd504b67c5 100644 --- a/doc/api/session.md +++ b/doc/api/session.md @@ -1,4 +1,4 @@ -# Session +# Session API ## Deprecation Notice diff --git a/doc/api/settings.md b/doc/api/settings.md index d99695ca986..eefbdda42ce 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -1,4 +1,4 @@ -# Application settings +# Application settings API These API calls allow you to read and modify GitLab instance application settings as appear in `/admin/application_settings`. You have to be an diff --git a/doc/api/sidekiq_metrics.md b/doc/api/sidekiq_metrics.md index ea10a26bcd0..b9500916cf2 100644 --- a/doc/api/sidekiq_metrics.md +++ b/doc/api/sidekiq_metrics.md @@ -1,4 +1,4 @@ -# Sidekiq Metrics +# Sidekiq Metrics API >**Note:** This endpoint is only available on GitLab 8.9 and above. diff --git a/doc/api/snippets.md b/doc/api/snippets.md index e09d930698e..efaab712367 100644 --- a/doc/api/snippets.md +++ b/doc/api/snippets.md @@ -1,4 +1,4 @@ -# Snippets +# Snippets API > [Introduced][ce-6373] in GitLab 8.15. @@ -48,6 +48,7 @@ Example response: "id": 1, "title": "test", "file_name": "add.rb", + "description": "Ruby test snippet", "author": { "id": 1, "username": "john_smith", @@ -73,16 +74,17 @@ POST /snippets Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `title` | String | yes | The title of a snippet | -| `file_name` | String | yes | The name of a snippet file | -| `content` | String | yes | The content of a snippet | -| `visibility` | String | yes | The snippet's visibility | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `title` | String | yes | The title of a snippet | +| `file_name` | String | yes | The name of a snippet file | +| `content` | String | yes | The content of a snippet | +| `description` | String | no | The description of a snippet | +| `visibility` | String | no | The snippet's visibility | ``` bash -curl --request POST --data '{"title": "This is a snippet", "content": "Hello world", "file_name": "test.txt", "visibility": "internal" }' --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets +curl --request POST --data '{"title": "This is a snippet", "content": "Hello world", "description": "Hello World snippet", "file_name": "test.txt", "visibility": "internal" }' --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets ``` Example response: @@ -92,6 +94,7 @@ Example response: "id": 1, "title": "This is a snippet", "file_name": "test.txt", + "description": "Hello World snippet", "author": { "id": 1, "username": "john_smith", @@ -117,13 +120,14 @@ PUT /snippets/:id Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | Integer | yes | The ID of a snippet | -| `title` | String | no | The title of a snippet | -| `file_name` | String | no | The name of a snippet file | -| `content` | String | no | The content of a snippet | -| `visibility` | String | no | The snippet's visibility | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | Integer | yes | The ID of a snippet | +| `title` | String | no | The title of a snippet | +| `file_name` | String | no | The name of a snippet file | +| `description` | String | no | The description of a snippet | +| `content` | String | no | The content of a snippet | +| `visibility` | String | no | The snippet's visibility | ``` bash @@ -137,6 +141,7 @@ Example response: "id": 1, "title": "test", "file_name": "add.rb", + "description": "description of snippet", "author": { "id": 1, "username": "john_smith", diff --git a/doc/api/system_hooks.md b/doc/api/system_hooks.md index bad380794c1..9750475f0a6 100644 --- a/doc/api/system_hooks.md +++ b/doc/api/system_hooks.md @@ -1,4 +1,4 @@ -# System hooks +# System hooks API All methods require administrator authorization. diff --git a/doc/api/tags.md b/doc/api/tags.md index 0f6c4e6794e..54f092d1d30 100644 --- a/doc/api/tags.md +++ b/doc/api/tags.md @@ -1,4 +1,4 @@ -# Tags +# Tags API ## List project repository tags diff --git a/doc/api/templates/gitignores.md b/doc/api/templates/gitignores.md index 3f2f4ed54e0..d3f5c88ca90 100644 --- a/doc/api/templates/gitignores.md +++ b/doc/api/templates/gitignores.md @@ -1,4 +1,4 @@ -# Gitignores +# Gitignores API ## List gitignore templates diff --git a/doc/api/templates/gitlab_ci_ymls.md b/doc/api/templates/gitlab_ci_ymls.md index 27e8973da58..bdb128fc336 100644 --- a/doc/api/templates/gitlab_ci_ymls.md +++ b/doc/api/templates/gitlab_ci_ymls.md @@ -1,4 +1,4 @@ -# GitLab CI YMLs +# GitLab CI YMLs API ## List GitLab CI YML templates diff --git a/doc/api/templates/licenses.md b/doc/api/templates/licenses.md index 33018f0c53f..8d1006e08c5 100644 --- a/doc/api/templates/licenses.md +++ b/doc/api/templates/licenses.md @@ -1,4 +1,4 @@ -# Licenses +# Licenses API ## List license templates diff --git a/doc/api/todos.md b/doc/api/todos.md index 77667a57195..dd4c737b729 100644 --- a/doc/api/todos.md +++ b/doc/api/todos.md @@ -1,4 +1,4 @@ -# Todos +# Todos API > [Introduced][ce-3188] in GitLab 8.10. diff --git a/doc/api/users.md b/doc/api/users.md index 86027bcc05c..f4167ba2605 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -1,4 +1,4 @@ -# Users +# Users API ## List users @@ -300,6 +300,9 @@ DELETE /users/:id Parameters: - `id` (required) - The ID of the user +- `hard_delete` (optional) - If true, contributions that would usually be + [moved to the ghost user](../user/profile/account/delete_account.md#associated-records) + will be deleted instead, as well as groups owned solely by this user. ## User @@ -698,147 +701,8 @@ Will return `201 OK` on success, `404 User Not Found` is user cannot be found or ### Get user contribution events -Get the contribution events for the specified user, sorted from newest to oldest. +Please refer to the [Events API documentation](events.md#get-user-contribution-events) -``` -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/v4/users/: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/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/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/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/root" - }, - "created_at": "2015-12-04T10:33:56.698Z", - "system": 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/root" - }, - "author_username": "root" - } -] -``` ## Get all impersonation tokens of a user diff --git a/doc/api/v3_to_v4.md b/doc/api/v3_to_v4.md index 8e002fe0022..9db8e0351cf 100644 --- a/doc/api/v3_to_v4.md +++ b/doc/api/v3_to_v4.md @@ -1,8 +1,10 @@ -# V3 to V4 version +# API V3 to API V4 Since GitLab 9.0, API V4 is the preferred version to be used. -V3 will remain working until at least GitLab 9.3. The V3 API documentation is still [available](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-16-stable/doc/api/README.md). +API V3 will be removed in GitLab 9.5, to be released on August 22, 2017. In the +meantime, we advise you to make any necessary changes to applications that use +V3. The V3 API documentation is still [available](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-16-stable/doc/api/README.md). Below are the changes made between V3 and V4. diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md index 1702c2184f2..6892905dd94 100644 --- a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md +++ b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md @@ -1,6 +1,6 @@ # How to configure LDAP with GitLab CE -> **Type:** admin guide || +> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** admin guide || > **Level:** intermediary || > **Author:** [Chris Wilson](https://gitlab.com/MrChrisW) || > **Publication date:** 2017/05/03 diff --git a/doc/articles/how_to_install_git/index.md b/doc/articles/how_to_install_git/index.md new file mode 100644 index 00000000000..66d866b2d09 --- /dev/null +++ b/doc/articles/how_to_install_git/index.md @@ -0,0 +1,66 @@ +# Installing Git + +> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** user guide || +> **Level:** beginner || +> **Author:** [Sean Packham](https://gitlab.com/SeanPackham) || +> **Publication date:** 2017/05/15 + +To begin contributing to GitLab projects +you will need to install the Git client on your computer. +This article will show you how to install Git on macOS, Ubuntu Linux and Windows. + +## Install Git on macOS using the Homebrew package manager + +Although it is easy to use the version of Git shipped with macOS +or install the latest version of Git on macOS by downloading it from the project website, +we recommend installing it via Homebrew to get access to +an extensive selection of dependancy managed libraries and applications. + +If you are sure you don't need access to any additional development libraries +or don't have approximately 15gb of available disk space for Xcode and Homebrew +use one of the the aforementioned methods. + +### Installing Xcode + +Xcode is needed by Homebrew to build dependencies. +You can install [XCode](https://developer.apple.com/xcode/) +through the macOS App Store. + +### Installing Homebrew + +Once Xcode is installed browse to the [Homebrew website](http://brew.sh/index.html) +for the official Homebrew installation instructions. + +### Installing Git via Homebrew + +With Homebrew installed you are now ready to install Git. +Open a Terminal and enter in the following command: + +```bash +brew install git +``` + +Congratulations you should now have Git installed via Homebrew. +Next read our article on [adding an SSH key to GitLab](../../ssh/README.md). + +## Install Git on Ubuntu Linux + +On Ubuntu and other Linux operating systems +it is recommended to use the built in package manager to install Git. + +Open a Terminal and enter in the following commands +to install the latest Git from the official Git maintained package archives: + +```bash +sudo apt-add-repository ppa:git-core/ppa +sudo apt-get update +sudo apt-get install git +``` + +Congratulations you should now have Git installed via the Ubuntu package manager. +Next read our article on [adding an SSH key to GitLab](../../ssh/README.md). + +## Installing Git on Windows from the Git website + +Browse to the [Git website](https://git-scm.com/) and download and install Git for Windows. +Next read our article on [adding an SSH key to GitLab](../../ssh/README.md). diff --git a/doc/articles/index.md b/doc/articles/index.md index 49db64134f5..342fa88e80f 100644 --- a/doc/articles/index.md +++ b/doc/articles/index.md @@ -12,6 +12,10 @@ They are written by members of the GitLab Team and by - **LDAP** - [How to configure LDAP with GitLab CE](how_to_configure_ldap_gitlab_ce/index.md) +## Git + +- [How to install Git](how_to_install_git/index.md) + ## GitLab Pages - **GitLab Pages from A to Z** diff --git a/doc/ci/README.md b/doc/ci/README.md index 84533ea1d48..ca7266ac68f 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -1,6 +1,6 @@ # GitLab Continuous Integration (GitLab CI) - + The benefits of Continuous Integration are huge when automation plays an integral part of your workflow. GitLab comes with built-in Continuous @@ -86,7 +86,7 @@ You can change the default behavior of GitLab CI in your whole GitLab instance as well as in each project. - **Project specific** - - [CI/CD pipelines settings](../user/project/pipelines/settings.md) + - [Pipelines settings](../user/project/pipelines/settings.md) - [Learn how to enable or disable GitLab CI](enable_or_disable_ci.md) - **Affecting the whole GitLab instance** - [Continuous Integration admin settings](../user/admin_area/settings/continuous_integration.md) @@ -109,6 +109,7 @@ Here is an collection of tutorials and guides on setting up your CI pipeline. - [Scala](examples/test-scala-application.md) - [Phoenix](examples/test-phoenix-application.md) - [Run PHP Composer & NPM scripts then deploy them to a staging server](examples/deployment/composer-npm-deploy.md) + - [Analyze code quality with the Code Climate CLI](examples/code_climate.md) - **Blog posts** - [Automated Debian packaging](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/) - [Spring boot application with GitLab CI and Kubernetes](https://about.gitlab.com/2016/12/14/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/) diff --git a/doc/ci/api/README.md b/doc/ci/api/README.md index 4ca8d92d7cc..98f37935427 100644 --- a/doc/ci/api/README.md +++ b/doc/ci/api/README.md @@ -1,3 +1 @@ -# GitLab CI API - This document was moved to a [new location](../../api/ci/README.md). diff --git a/doc/ci/api/builds.md b/doc/ci/api/builds.md index f5bd3181c02..0563a367609 100644 --- a/doc/ci/api/builds.md +++ b/doc/ci/api/builds.md @@ -1,3 +1 @@ -# Builds API - This document was moved to a [new location](../../api/ci/builds.md). diff --git a/doc/ci/api/runners.md b/doc/ci/api/runners.md index b14ea99db76..1027363851c 100644 --- a/doc/ci/api/runners.md +++ b/doc/ci/api/runners.md @@ -1,3 +1 @@ -# Runners API - This document was moved to a [new location](../../api/ci/runners.md). diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index ffa0831290a..408d46a756c 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -37,7 +37,7 @@ GitLab Runner then executes job scripts as the `gitlab-runner` user. ```bash sudo gitlab-ci-multi-runner register -n \ - --url https://gitlab.com/ci \ + --url https://gitlab.com/ \ --registration-token REGISTRATION_TOKEN \ --executor shell \ --description "My Runner" @@ -94,7 +94,7 @@ In order to do that, follow the steps: ```bash sudo gitlab-ci-multi-runner register -n \ - --url https://gitlab.com/ci \ + --url https://gitlab.com/ \ --registration-token REGISTRATION_TOKEN \ --executor docker \ --description "My Docker Runner" \ @@ -112,7 +112,7 @@ In order to do that, follow the steps: ``` [[runners]] - url = "https://gitlab.com/ci" + url = "https://gitlab.com/" token = TOKEN executor = "docker" [runners.docker] @@ -179,7 +179,7 @@ In order to do that, follow the steps: ```bash sudo gitlab-ci-multi-runner register -n \ - --url https://gitlab.com/ci \ + --url https://gitlab.com/ \ --registration-token REGISTRATION_TOKEN \ --executor docker \ --description "My Docker Runner" \ @@ -197,7 +197,7 @@ In order to do that, follow the steps: ``` [[runners]] - url = "https://gitlab.com/ci" + url = "https://gitlab.com/" token = REGISTRATION_TOKEN executor = "docker" [runners.docker] diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index f025a7e3496..7709541ba9d 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -140,21 +140,58 @@ that runner. ## Define an image from a private Docker registry -Starting with GitLab Runner 0.6.0, you are able to define images located to -private registries that could also require authentication. - -All you have to do is be explicit on the image definition in `.gitlab-ci.yml`. - -```yaml -image: my.registry.tld:5000/namepace/image:tag -``` - -In the example above, GitLab Runner will look at `my.registry.tld:5000` for the -image `namespace/image:tag`. - -If the repository is private you need to authenticate your GitLab Runner in the -registry. Learn how to do that on -[GitLab Runner's documentation][runner-priv-reg]. +> **Notes:** +- This feature requires GitLab Runner **1.8** or higher +- For GitLab Runner versions **>= 0.6, <1.8** there was a partial + support for using private registries, which required manual configuration + of credentials on runner's host. We recommend to upgrade your Runner to + at least version **1.8** if you want to use private registries. +- If the repository is private you need to authenticate your GitLab Runner in the + registry. Learn more about how [GitLab Runner works in this case][runner-priv-reg]. + +As an example, let's assume that you want to use the `registry.example.com/private/image:latest` +image which is private and requires you to login into a private container registry. +To configure access for `registry.example.com`, follow these steps: + +1. Do a `docker login` on your computer: + + ```bash + docker login registry.example.com --username my_username --password my_password + ``` + +1. Copy the content of `~/.docker/config.json` +1. Create a [secret variable] `DOCKER_AUTH_CONFIG` with the content of the + Docker configuration file as the value: + + ```json + { + "auths": { + "registry.example.com": { + "auth": "bXlfdXNlcm5hbWU6bXlfcGFzc3dvcmQ=" + } + } + } + ``` + +1. Do a `docker logout` on your computer if you don't need access to the + registry from it: + + ```bash + docker logout registry.example.com + ``` + +1. You can now use any private image from `registry.example.com` defined in + `image` and/or `services` in your [`.gitlab-ci.yml` file][yaml-priv-reg]: + + ```yaml + image: my.registry.tld:5000/namespace/image:tag + ``` + + In the example above, GitLab Runner will look at `my.registry.tld:5000` for the + image `namespace/image:tag`. + +You can add configuration for as many registries as you want, adding more +registries to the `"auths"` hash as described above. ## Accessing the services @@ -283,4 +320,5 @@ creation. [tutum/wordpress]: https://hub.docker.com/r/tutum/wordpress/ [postgres-hub]: https://hub.docker.com/r/_/postgres/ [mysql-hub]: https://hub.docker.com/r/_/mysql/ -[runner-priv-reg]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/blob/master/docs/configuration/advanced-configuration.md#using-a-private-docker-registry +[runner-priv-reg]: http://docs.gitlab.com/runner/configuration/advanced-configuration.html#using-a-private-container-registry +[secret variable]: ../variables/README.md#secret-variables diff --git a/doc/ci/environments.md b/doc/ci/environments.md index f047a076c67..3393030210e 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -94,6 +94,12 @@ the name given in `.gitlab-ci.yml` (with any variables expanded), while the second is a "cleaned-up" version of the name, suitable for use in URLs, DNS, etc. +>**Note:** +Starting with GitLab 9.3, the environment URL is exposed to the Runner via +`$CI_ENVIRONMENT_URL`. The URL would be expanded from `.gitlab-ci.yml`, or if +the URL was not defined there, the external URL from the environment would be +used. + To sum up, with the above `.gitlab-ci.yml` we have achieved that: - All branches will run the `test` and `build` jobs. @@ -442,7 +448,8 @@ and/or `production`) you can see this information in the merge request itself.  -### Go directly from source files to public pages on the environment +### <a name="route-map"></a>Go directly from source files to public pages on the environment + > Introduced in GitLab 8.17. diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md index 33c27b39a8a..2458cb959ab 100644 --- a/doc/ci/examples/README.md +++ b/doc/ci/examples/README.md @@ -55,6 +55,7 @@ Apart from those, here is an collection of tutorials and guides on setting up yo - [Using `dpl` as deployment tool](deployment/README.md) - [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) +- [Analyze code quality with the Code Climate CLI](code_climate.md) - **Articles:** - [Continuous Deployment with GitLab: how to build and deploy a Debian Package with GitLab CI](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/) diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md new file mode 100644 index 00000000000..a047e809788 --- /dev/null +++ b/doc/ci/examples/code_climate.md @@ -0,0 +1,34 @@ +# Analyze project code quality with Code Climate CLI + +This example shows how to run [Code Climate CLI][cli] on your code by using +GitLab CI and Docker. + +First, you need GitLab Runner with [docker-in-docker executor][dind]. + +Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codeclimate`: + +```yaml +codeclimate: + image: docker:latest + variables: + DOCKER_DRIVER: overlay + services: + - docker:dind + script: + - docker pull codeclimate/codeclimate + - docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate init + - docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate analyze -f json > codeclimate.json + artifacts: + paths: [codeclimate.json] +``` + +This will create a `codeclimate` job in your CI pipeline and will allow you to +download and analyze the report artifact in JSON format. + +For GitLab [Enterprise Edition Starter][ee] users, this information can be automatically +extracted and shown right in the merge request widget. [Learn more on code quality +diffs in merge requests](http://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.md). + +[cli]: https://github.com/codeclimate/codeclimate +[dind]: ../docker/using_docker_build.md#use-docker-in-docker-executor +[ee]: https://about.gitlab.com/gitlab-ee/ diff --git a/doc/ci/examples/deployment/README.md b/doc/ci/examples/deployment/README.md index 7b0995597c4..e80e246c5dd 100644 --- a/doc/ci/examples/deployment/README.md +++ b/doc/ci/examples/deployment/README.md @@ -111,7 +111,7 @@ We also use two secure variables: ## Storing API keys Secure Variables can added by going to your project's -**Settings ➔ CI/CD Pipelines ➔ Secret variables**. The variables that are defined +**Settings ➔ Pipelines ➔ Secret variables**. The variables that are defined in the project settings are sent along with the build script to the Runner. The secure variables are stored out of the repository. Never store secrets in your project's `.gitlab-ci.yml`. It is also important that the secret's value diff --git a/doc/ci/examples/test-and-deploy-python-application-to-heroku.md b/doc/ci/examples/test-and-deploy-python-application-to-heroku.md index e4d3970deac..73aebaf6d7f 100644 --- a/doc/ci/examples/test-and-deploy-python-application-to-heroku.md +++ b/doc/ci/examples/test-and-deploy-python-application-to-heroku.md @@ -55,11 +55,11 @@ You can do this through the [Dashboard](https://dashboard.heroku.com/). ### Create runner First install [Docker Engine](https://docs.docker.com/installation/). To build this project you also need to have [GitLab Runner](https://about.gitlab.com/gitlab-ci/#gitlab-runner). -You can use public runners available on `gitlab.com/ci`, but you can register your own: +You can use public runners available on `gitlab.com`, but you can register your own: ``` gitlab-ci-multi-runner register \ --non-interactive \ - --url "https://gitlab.com/ci/" \ + --url "https://gitlab.com/" \ --registration-token "PROJECT_REGISTRATION_TOKEN" \ --description "python-3.5" \ --executor "docker" \ diff --git a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md index 42f15a27f12..6fa64a67e82 100644 --- a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md +++ b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md @@ -50,11 +50,11 @@ You can do this through the [Dashboard](https://dashboard.heroku.com/). ### Create runner First install [Docker Engine](https://docs.docker.com/installation/). To build this project you also need to have [GitLab Runner](https://about.gitlab.com/gitlab-ci/#gitlab-runner). -You can use public runners available on `gitlab.com/ci`, but you can register your own: +You can use public runners available on `gitlab.com`, but you can register your own: ``` gitlab-ci-multi-runner register \ --non-interactive \ - --url "https://gitlab.com/ci/" \ + --url "https://gitlab.com/" \ --registration-token "PROJECT_REGISTRATION_TOKEN" \ --description "ruby-2.2" \ --executor "docker" \ diff --git a/doc/ci/examples/test-scala-application.md b/doc/ci/examples/test-scala-application.md index 01c13941c21..09d83c33f95 100644 --- a/doc/ci/examples/test-scala-application.md +++ b/doc/ci/examples/test-scala-application.md @@ -54,7 +54,7 @@ You can use other versions of Scala and SBT by defining them in ## Display test coverage in job Add the `Coverage was \[\d+.\d+\%\]` regular expression in the -**Settings ➔ CI/CD Pipelines ➔ Coverage report** project setting to +**Settings ➔ Pipelines ➔ Coverage report** project setting to retrieve the [test coverage] rate from the build trace and have it displayed with your jobs. diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 30f209f80eb..41cae58782d 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -155,7 +155,7 @@ Find more information about different Runners in the [Runners](../runners/README.md) documentation. You can find whether any Runners are assigned to your project by going to -**Settings ➔ Runners**. Setting up a Runner is easy and straightforward. The +**Settings ➔ CI/CD Pipelines**. Setting up a Runner is easy and straightforward. The official Runner supported by GitLab is written in Go and its documentation can be found at <https://docs.gitlab.com/runner/>. @@ -168,7 +168,7 @@ Follow the links above to set up your own Runner or use a Shared Runner as described in the next section. Once the Runner has been set up, you should see it on the Runners page of your -project, following **Settings ➔ Runners**. +project, following **Settings ➔ CI/CD Pipelines**.  @@ -181,7 +181,7 @@ These are special virtual machines that run on GitLab's infrastructure and can build any project. To enable the **Shared Runners** you have to go to your project's -**Settings ➔ Runners** and click **Enable shared runners**. +**Settings ➔ CI/CD Pipelines** and click **Enable shared runners**. [Read more on Shared Runners](../runners/README.md). diff --git a/doc/ci/triggers/README.md b/doc/ci/triggers/README.md index 27cdaa9978b..cb646827fb4 100644 --- a/doc/ci/triggers/README.md +++ b/doc/ci/triggers/README.md @@ -12,7 +12,7 @@ with an API call. ## Add a trigger You can add a new trigger by going to your project's -**Settings ➔ CI/CD Pipelines ➔ Triggers**. The **Add trigger** button will +**Settings ➔ Pipelines ➔ Triggers**. The **Add trigger** button will create a new token which you can then use to trigger a rerun of this particular project's pipeline. @@ -60,7 +60,7 @@ POST /projects/:id/trigger/pipeline The required parameters are the trigger's `token` and the Git `ref` on which the trigger will be performed. Valid refs are the branch and the tag. The `:id` of a project can be found by [querying the API](../../api/projects.md) -or by visiting the **CI/CD Pipelines** settings page which provides +or by visiting the **Pipelines** settings page which provides self-explanatory examples. When a rerun of a pipeline is triggered, the information is exposed in GitLab's diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 045d3821f66..76ba78ea7be 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -10,7 +10,7 @@ The variables can be overwritten and they take precedence over each other in this order: 1. [Trigger variables][triggers] (take precedence over all) -1. [Secret variables](#secret-variables) +1. [Secret variables](#secret-variables) or [protected secret variables](#protected-secret-variables) 1. YAML-defined [job-level variables](../yaml/README.md#job-variables) 1. YAML-defined [global variables](../yaml/README.md#variables) 1. [Deployment variables](#deployment-variables) @@ -43,6 +43,7 @@ future GitLab releases.** | **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled | | **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job | | **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. | +| **CI_ENVIRONMENT_URL** | 9.3 | all | The URL of the environment for this job | | **CI_JOB_ID** | 9.0 | all | The unique id of the current job that GitLab CI uses internally | | **CI_JOB_MANUAL** | 8.12 | all | The flag to indicate that job was manually started | | **CI_JOB_NAME** | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` | @@ -56,9 +57,10 @@ future GitLab releases.** | **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] | | **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run | | **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally | -| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built | +| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) | | **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built | | **CI_PROJECT_PATH** | 8.10 | 0.5 | The namespace with project name | +| **CI_PROJECT_PATH_SLUG** | 9.3 | all | `$CI_PROJECT_PATH` lowercased and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. | | **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project | | **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 returns the address of the registry tied to the specific project | @@ -118,11 +120,11 @@ The YAML-defined variables are also set to all created tune them. Variables can be defined at a global level, but also at a job level. To turn off -global defined variables in your job, define an empty array: +global defined variables in your job, define an empty hash: ```yaml job_name: - variables: [] + variables: {} ``` You are able to use other variables inside your variable definition (or escape them with `$$`): @@ -152,10 +154,26 @@ available in the build environment. It's the recommended method to use for storing things like passwords, secret keys and credentials. Secret variables can be added by going to your project's -**Settings ➔ CI/CD Pipelines**, then finding the section called -**Secret Variables**. +**Settings ➔ Pipelines**, then finding the section called +**Secret variables**. -Once you set them, they will be available for all subsequent jobs. +Once you set them, they will be available for all subsequent pipelines. + +## Protected secret variables + +>**Notes:** +This feature requires GitLab 9.3 or higher. + +Secret variables could be protected. Whenever a secret variable is +protected, it would only be securely passed to pipelines running on the +[protected branches] or [protected tags]. The other pipelines would not get any +protected variables. + +Protected variables can be added by going to your project's +**Settings ➔ Pipelines**, then finding the section called +**Secret variables**, and check *Protected*. + +Once you set them, they will be available for all subsequent pipelines. ## Deployment variables @@ -327,20 +345,45 @@ All variables are set as environment variables in the build environment, and they are accessible with normal methods that are used to access such variables. In most cases `bash` or `sh` is used to execute the job script. -To access the variables (predefined and user-defined) in a `bash`/`sh` environment, -prefix the variable name with the dollar sign (`$`): +To access environment variables, use the syntax for your Runner's [shell][shellexecutors]. + +| Shell | Usage | +|----------------------|-----------------| +| bash/sh | `$variable` | +| windows batch | `%variable%` | +| PowerShell | `$env:variable` | + +To access environment variables in bash, prefix the variable name with (`$`): + +```yaml +job_name: + script: + - echo $CI_JOB_ID +``` + +To access environment variables in **Windows Batch**, surround the variable +with (`%`): +```yaml +job_name: + script: + - echo %CI_JOB_ID% ``` + +To access environment variables in a **Windows PowerShell** environment, prefix +the variable name with (`$env:`): + +```yaml job_name: script: - - echo $CI_job_ID + - echo $env:CI_JOB_ID ``` You can also list all environment variables with the `export` command, but be aware that this will also expose the values of all the secret variables you set, in the job log: -``` +```yaml job_name: script: - export @@ -385,3 +428,6 @@ export CI_REGISTRY_PASSWORD="longalfanumstring" [runner]: https://docs.gitlab.com/runner/ [triggered]: ../triggers/README.md [triggers]: ../triggers/README.md#pass-job-variables-to-a-trigger +[protected branches]: ../../user/project/protected_branches.md +[protected tags]: ../../user/project/protected_tags.md +[shellexecutors]: https://docs.gitlab.com/runner/executors/ diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index e542b1119ea..8a0662db6fd 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -147,6 +147,10 @@ variables: DATABASE_URL: "postgres://postgres@postgres/my_database" ``` +>**Note:** +Integers (as well as strings) are legal both for variable's name and value. +Floats are not legal and cannot be used. + These variables can be later used in all executed commands and scripts. The YAML-defined variables are also set to all created service containers, thus allowing to fine tune them. Variables can be also defined on a @@ -293,6 +297,15 @@ cache: untracked: true ``` +If you use **Windows PowerShell** to run your shell scripts you need to replace +`$` with `$env:`: + +```yaml +cache: + key: "$env:CI_JOB_STAGE/$env:CI_COMMIT_REF_NAME" + untracked: true +``` + ## Jobs `.gitlab-ci.yml` allows you to specify an unlimited number of jobs. Each job @@ -380,7 +393,8 @@ There are a few rules that apply to the usage of refs policy: * `only` and `except` are inclusive. If both `only` and `except` are defined in a job specification, the ref is filtered by `only` and `except`. * `only` and `except` allow the use of regular expressions. -* `only` and `except` allow the use of special keywords: `branches`, `tags`, and `triggers`. +* `only` and `except` allow the use of special keywords: +`api`, `branches`, `external`, `tags`, `pushes`, `schedules`, `triggers`, and `web` * `only` and `except` allow to specify a repository path to filter jobs for forks. @@ -398,7 +412,7 @@ job: ``` In this example, `job` will run only for refs that are tagged, or if a build is -explicitly requested via an API trigger. +explicitly requested via an API trigger or a [Pipeline Schedule](../../user/project/pipelines/schedules.md). ```yaml job: @@ -406,6 +420,7 @@ job: only: - tags - triggers + - schedules ``` The repository path can be used to have jobs executed only for the parent @@ -430,11 +445,11 @@ but allows you to define job-specific variables. When the `variables` keyword is used on a job level, it overrides the global YAML job variables and predefined ones. To turn off global defined variables -in your job, define an empty array: +in your job, define an empty hash: ```yaml job_name: - variables: [] + variables: {} ``` Job variables priority is defined in the [variables documentation][variables]. @@ -587,7 +602,7 @@ Optional manual actions have `allow_failure: true` set by default. **Manual actions are considered to be write actions, so permissions for protected branches are used when user wants to trigger an action. In other words, in order to trigger a manual action assigned to a branch that the -pipeline is running for, user needs to have ability to push to this branch.** +pipeline is running for, user needs to have ability to merge to this branch.** ### environment @@ -905,6 +920,16 @@ job: untracked: true ``` +If you use **Windows PowerShell** to run your shell scripts you need to replace +`$` with `$env:`: + +```yaml +job: + artifacts: + name: "$env:CI_JOB_STAGE_$env:CI_COMMIT_REF_NAME" + untracked: true +``` + #### artifacts:when > Introduced in GitLab 8.9 and GitLab Runner v1.3.0. @@ -1101,6 +1126,36 @@ variables: GIT_STRATEGY: none ``` +## Git Checkout + +> Introduced in GitLab Runner 9.3 + +The `GIT_CHECKOUT` variable can be used when the `GIT_STRATEGY` is set to either +`clone` or `fetch` to specify whether a `git checkout` should be run. If not +specified, it defaults to true. Like `GIT_STRATEGY`, it can be set in either the +global [`variables`](#variables) section or the [`variables`](#job-variables) +section for individual jobs. + +If set to `false`, the Runner will: + +- when doing `fetch` - update the repository and leave working copy on + the current revision, +- when doing `clone` - clone the repository and leave working copy on the + default branch. + +Having this setting set to `true` will mean that for both `clone` and `fetch` +strategies the Runner will checkout the working copy to a revision related +to the CI pipeline: + +```yaml +variables: + GIT_STRATEGY: clone + GIT_CHECKOUT: false +script: + - git checkout master + - git merge $CI_BUILD_REF_NAME +``` + ## Git Submodule Strategy > Requires GitLab Runner v1.10+. @@ -1158,7 +1213,7 @@ Example: ```yaml variables: - GET_SOURCES_ATTEMPTS: "3" + GET_SOURCES_ATTEMPTS: 3 ``` You can set them in the global [`variables`](#variables) section or the diff --git a/doc/customization/libravatar.md b/doc/customization/libravatar.md index c46ce2ee203..9bd22d3966d 100644 --- a/doc/customization/libravatar.md +++ b/doc/customization/libravatar.md @@ -16,7 +16,7 @@ the configuration options as follows: ```yml gravatar: enabled: true - # gravatar URLs: possible placeholders: %{hash} %{size} %{email} + # gravatar URLs: possible placeholders: %{hash} %{size} %{email} %{username} plain_url: "http://cdn.libravatar.org/avatar/%{hash}?s=%{size}&d=identicon" ``` @@ -25,7 +25,7 @@ the configuration options as follows: ```yml gravatar: enabled: true - # gravatar URLs: possible placeholders: %{hash} %{size} %{email} + # gravatar URLs: possible placeholders: %{hash} %{size} %{email} %{username} ssl_url: "https://seccdn.libravatar.org/avatar/%{hash}?s=%{size}&d=identicon" ``` diff --git a/doc/development/README.md b/doc/development/README.md index e7dd746b9bf..a3cae34a7ec 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -42,12 +42,21 @@ - [Sidekiq debugging](sidekiq_debugging.md) - [Object state models](object_state_models.md) - [Building a package for testing purposes](build_test_package.md) +- [Manage feature flags](feature_flags.md) ## Databases - [What requires downtime?](what_requires_downtime.md) - [Adding database indexes](adding_database_indexes.md) - [Post Deployment Migrations](post_deployment_migrations.md) +- [Foreign Keys & Associations](foreign_keys.md) +- [Serializing Data](serializing_data.md) +- [Polymorphic Associations](polymorphic_associations.md) +- [Single Table Inheritance](single_table_inheritance.md) + +## i18n + +- [Internationalization for GitLab](i18n_guide.md) ## i18n diff --git a/doc/development/architecture.md b/doc/development/architecture.md index 4eb7a8eee48..acd5e3c2093 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -4,7 +4,7 @@ There are two editions of GitLab: [Enterprise Edition](https://about.gitlab.com/gitlab-ee/) (EE) and [Community Edition](https://about.gitlab.com/gitlab-ce/) (CE). GitLab CE is delivered via git from the [gitlabhq repository](https://gitlab.com/gitlab-org/gitlab-ce/tree/master). New versions of GitLab are released in stable branches and the master branch is for bleeding edge development. -EE releases are available not long after CE releases. To obtain the GitLab EE there is a [repository at gitlab.com](https://gitlab.com/subscribers/gitlab-ee). For more information about the release process see the section 'New versions and upgrading' in the readme. +EE releases are available not long after CE releases. To obtain the GitLab EE there is a [repository at gitlab.com](https://gitlab.com/gitlab-org/gitlab-ee). For more information about the release process see the section 'New versions and upgrading' in the readme. Both EE and CE require some add-on components called gitlab-shell and Gitaly. These components are available from the [gitlab-shell](https://gitlab.com/gitlab-org/gitlab-shell/tree/master) and [gitaly](https://gitlab.com/gitlab-org/gitaly/tree/master) repositories respectively. New versions are usually tags but staying on the master branch will give you the latest stable version. New releases are generally around the same time as GitLab CE releases with exception for informal security updates deemed critical. @@ -12,7 +12,7 @@ Both EE and CE require some add-on components called gitlab-shell and Gitaly. Th You can imagine GitLab as a physical office. -**The repositories** are the goods GitLab handling. +**The repositories** are the goods GitLab handles. They can be stored in a warehouse. This can be either a hard disk, or something more complex, such as a NFS filesystem; @@ -54,7 +54,7 @@ To serve repositories over SSH there's an add-on application called gitlab-shell ### Components - +<img src="https://docs.google.com/drawings/d/1fBzAyklyveF-i-2q-OHUIqDkYfjjxC4mq5shwKSZHLs/pub?w=987&h=797"> _[edit diagram (for GitLab team members only)](https://docs.google.com/drawings/d/1fBzAyklyveF-i-2q-OHUIqDkYfjjxC4mq5shwKSZHLs/edit)_ @@ -66,7 +66,9 @@ When serving repositories over HTTP/HTTPS GitLab utilizes the GitLab API to reso The add-on component gitlab-shell serves repositories over SSH. It manages the SSH keys within `/home/git/.ssh/authorized_keys` which should not be manually edited. gitlab-shell accesses the bare repositories through Gitaly to serve git objects and communicates with redis to submit jobs to Sidekiq for GitLab to process. gitlab-shell queries the GitLab API to determine authorization and access. -Gitaly executes git operations from gitlab-shell and Workhorse, and provides an API to the GitLab web app to get attributes from git (e.g. title, branches, tags, other meta data), and to get blobs (e.g. diffs, commits, files) +Gitaly executes git operations from gitlab-shell and the GitLab web app, and provides an API to the GitLab web app to get attributes from git (e.g. title, branches, tags, other meta data), and to get blobs (e.g. diffs, commits, files). + +You may also be interested in the [production architecture of GitLab.com](https://about.gitlab.com/handbook/infrastructure/production-architecture/). ### Installation Folder Summary diff --git a/doc/development/build_test_package.md b/doc/development/build_test_package.md index 2bc1a700844..439d228baef 100644 --- a/doc/development/build_test_package.md +++ b/doc/development/build_test_package.md @@ -15,6 +15,10 @@ When you push a commit to either the gitlab-ce or gitlab-ee project, the pipeline for that commit will have a `build-package` manual action you can trigger. + + + + ## Specifying versions of components If you want to create a package from a specific branch, commit or tag of any of diff --git a/doc/development/code_review.md b/doc/development/code_review.md index be3dd1e2cc6..4ed89146072 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -6,18 +6,20 @@ There are a few rules to get your merge request accepted: 1. Your merge request should only be **merged by a [maintainer][team]**. 1. If your merge request includes only backend changes [^1], it must be - **approved by a [backend maintainer][team]**. + **approved by a [backend maintainer][projects]**. 1. If your merge request includes only frontend changes [^1], it must be - **approved by a [frontend maintainer][team]**. + **approved by a [frontend maintainer][projects]**. 1. If your merge request includes frontend and backend changes [^1], it must - be **approved by a [frontend and a backend maintainer][team]**. + be **approved by a [frontend and a backend maintainer][projects]**. 1. To lower the amount of merge requests maintainers need to review, you can - ask or assign any [reviewers][team] for a first review. + ask or assign any [reviewers][projects] for a first review. 1. If you need some guidance (e.g. it's your first merge request), feel free to ask one of the [Merge request coaches][team]. 1. The reviewer will assign the merge request to a maintainer once the reviewer is satisfied with the state of the merge request. +For more guidance, see [CONTRIBUTING.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md). + ## Best practices This guide contains advice and best practices for performing code review, and @@ -30,7 +32,7 @@ code is effective, understandable, and maintainable. Any developer can, and is encouraged to, perform code review on merge requests of colleagues and contributors. However, the final decision to accept a merge request is up to one the project's maintainers, denoted on the -[team page](https://about.gitlab.com/team). +[engineering projects][projects]. ### Everyone @@ -140,3 +142,6 @@ Largely based on the [thoughtbot code review guide]. --- [Return to Development documentation](README.md) + +[projects]: https://about.gitlab.com/handbook/engineering/projects/ +[team]: https://about.gitlab.com/team/ diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index 1e81905c081..5b09f79f143 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -198,10 +198,17 @@ You can combine one or more of the following: the `.md` document that you're working on is located. Always prepend their names with the name of the document that they will be included in. For example, if there is a document called `twitter.md`, then a valid image name - could be `twitter_login_screen.png`. + could be `twitter_login_screen.png`. [**Exception**: images for + [articles](writing_documentation.md#technical-articles) should be + put in a directory called `img` underneath `/articles/article_title/img/`, therefore, + there's no need to prepend the document name to their filenames.] - Images should have a specific, non-generic name that will differentiate them. - Keep all file names in lower case. - Consider using PNG images instead of JPEG. +- Compress all images with <https://tinypng.com/> or similar tool. +- Compress gifs with <https://ezgif.com/optimize> or similar toll. +- Images should be used (only when necessary) to _illustrate_ the description +of a process, not to _replace_ it. Inside the document: diff --git a/doc/development/fe_guide/img/testing_triangle.png b/doc/development/fe_guide/img/testing_triangle.png Binary files differnew file mode 100644 index 00000000000..7a9a848c2ee --- /dev/null +++ b/doc/development/fe_guide/img/testing_triangle.png diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md index a08694fb66a..64bcb4a0257 100644 --- a/doc/development/fe_guide/index.md +++ b/doc/development/fe_guide/index.md @@ -45,15 +45,11 @@ should be `new-feature`. branch from `new-feature`, let's call it `new-feature-step-2` and repeat the process done before. ```shell -* master -|\ -| * new-feature -| |\ -| | * new-feature-step-1 -| |\ -| | * new-feature-step-2 -| |\ -| | * new-feature-step-3 +master +└─ new-feature + ├─ new-feature-step-1 + ├─ new-feature-step-2 + └─ new-feature-step-3 ``` **Tips** diff --git a/doc/development/fe_guide/testing.md b/doc/development/fe_guide/testing.md index 157c13352ca..867c83f1e72 100644 --- a/doc/development/fe_guide/testing.md +++ b/doc/development/fe_guide/testing.md @@ -1,11 +1,20 @@ # Frontend Testing -There are two types of tests you'll encounter while developing frontend code -at GitLab. We use Karma and Jasmine for JavaScript unit testing, and RSpec -feature tests with Capybara for integration testing. +There are two types of test suites you'll encounter while developing frontend code +at GitLab. We use Karma and Jasmine for JavaScript unit and integration testing, and RSpec +feature tests with Capybara for e2e (end-to-end) integration testing. -Feature tests need to be written for all new features. Regression tests ought -to be written for all bug fixes to prevent them from recurring in the future. +Unit and feature tests need to be written for all new features. +Most of the time, you should use rspec for your feature tests. +There are cases where the behaviour you are testing is not worth the time spent running the full application, +for example, if you are testing styling, animation, edge cases or small actions that don't involve the backend, +you should write an integration test using Jasmine. + + + +_This diagram demonstrates the relative priority of each test type we use_ + +Regression tests should be written for bug fixes to prevent them from recurring in the future. See [the Testing Standards and Style Guidelines](../testing.md) for more information on general testing practices at GitLab. @@ -13,10 +22,12 @@ for more information on general testing practices at GitLab. ## Karma test suite GitLab uses the [Karma][karma] test runner with [Jasmine][jasmine] as its test -framework for our JavaScript unit tests. For tests that rely on DOM -manipulation, we generate HTML files using RSpec suites (see `spec/javascripts/fixtures/*.rb` for examples). +framework for our JavaScript unit and integration tests. For integration tests, +we generate HTML files using RSpec (see `spec/javascripts/fixtures/*.rb` for examples). Some fixtures are still HAML templates that are translated to HTML files using the same mechanism (see `static_fixtures.rb`). -Those will be migrated over time. +Adding these static fixtures should be avoided as they are harder to keep up to date with real views. +The existing static fixtures will be migrated over time. +Please see [gitlab-org/gitlab-ce#24753](https://gitlab.com/gitlab-org/gitlab-ce/issues/24753) to track our progress. Fixtures are served during testing by the [jasmine-jquery][jasmine-jquery] plugin. JavaScript tests live in `spec/javascripts/`, matching the folder structure @@ -28,7 +39,107 @@ browser and you will not have access to certain APIs, such as [`Notification`](https://developer.mozilla.org/en-US/docs/Web/API/notification), which will have to be stubbed. -### Writing tests +### Best practice + +#### Naming unit tests + +When writing describe test blocks to test specific functions/methods, +please use the method name as the describe block name. + +```javascript +// Good +describe('methodName', () => { + it('passes', () => { + expect(true).toEqual(true); + }); +}); + +// Bad +describe('#methodName', () => { + it('passes', () => { + expect(true).toEqual(true); + }); +}); + +// Bad +describe('.methodName', () => { + it('passes', () => { + expect(true).toEqual(true); + }); +}); +``` +#### Testing Promises + +When testing Promises you should always make sure that the test is asynchronous and rejections are handled. +Your Promise chain should therefore end with a call of the `done` callback and `done.fail` in case an error occurred. + +```javascript +// Good +it('tests a promise', (done) => { + promise + .then((data) => { + expect(data).toBe(asExpected); + }) + .then(done) + .catch(done.fail); +}); + +// Good +it('tests a promise rejection', (done) => { + promise + .then(done.fail) + .catch((error) => { + expect(error).toBe(expectedError); + }) + .then(done) + .catch(done.fail); +}); + +// Bad (missing done callback) +it('tests a promise', () => { + promise + .then((data) => { + expect(data).toBe(asExpected); + }) +}); + +// Bad (missing catch) +it('tests a promise', (done) => { + promise + .then((data) => { + expect(data).toBe(asExpected); + }) + .then(done) +}); + +// Bad (use done.fail in asynchronous tests) +it('tests a promise', (done) => { + promise + .then((data) => { + expect(data).toBe(asExpected); + }) + .then(done) + .catch(fail) +}); + +// Bad (missing catch) +it('tests a promise rejection', (done) => { + promise + .catch((error) => { + expect(error).toBe(expectedError); + }) + .then(done) +}); +``` + +#### Stubbing + +For unit tests, you should stub methods that are unrelated to the current unit you are testing. +If you need to use a prototype method, instantiate an instance of the class and call it there instead of mocking the instance completely. + +For integration tests, you should stub methods that will effect the stability of the test if they +execute their original behaviour. i.e. Network requests. + ### Vue.js unit tests See this [section][vue-test]. diff --git a/doc/development/feature_flags.md b/doc/development/feature_flags.md new file mode 100644 index 00000000000..5c6316b9ac6 --- /dev/null +++ b/doc/development/feature_flags.md @@ -0,0 +1,7 @@ +# Manage feature flags + +Starting from GitLab 9.3 we support feature flags via +[Flipper](https://github.com/jnunemaker/flipper/). You should use the `Feature` +class (defined in `lib/feature.rb`) in your code to get, set and list feature +flags. During runtime you can set the values for the gates via the +[admin API](../api/features.md). diff --git a/doc/development/foreign_keys.md b/doc/development/foreign_keys.md new file mode 100644 index 00000000000..0ab0deb156f --- /dev/null +++ b/doc/development/foreign_keys.md @@ -0,0 +1,63 @@ +# Foreign Keys & Associations + +When adding an association to a model you must also add a foreign key. For +example, say you have the following model: + +```ruby +class User < ActiveRecord::Base + has_many :posts +end +``` + +Here you will need to add a foreign key on column `posts.user_id`. This ensures +that data consistency is enforced on database level. Foreign keys also mean that +the database can very quickly remove associated data (e.g. when removing a +user), instead of Rails having to do this. + +## Adding Foreign Keys In Migrations + +Foreign keys can be added concurrently using `add_concurrent_foreign_key` as +defined in `Gitlab::Database::MigrationHelpers`. See the [Migration Style +Guide](migration_style_guide.md) for more information. + +Keep in mind that you can only safely add foreign keys to existing tables after +you have removed any orphaned rows. The method `add_concurrent_foreign_key` +does not take care of this so you'll need to do so manually. + +## Cascading Deletes + +Every foreign key must define an `ON DELETE` clause, and in 99% of the cases +this should be set to `CASCADE`. + +## Indexes + +When adding a foreign key in PostgreSQL the column is not indexed automatically, +thus you must also add a concurrent index. Not doing so will result in cascading +deletes being very slow. + +## Dependent Removals + +Don't define options such as `dependent: :destroy` or `dependent: :delete` when +defining an association. Defining these options means Rails will handle the +removal of data, instead of letting the database handle this in the most +efficient way possible. + +In other words, this is bad and should be avoided at all costs: + +```ruby +class User < ActiveRecord::Base + has_many :posts, dependent: :destroy +end +``` + +Should you truly have a need for this it should be approved by a database +specialist first. + +You should also not define any `before_destroy` or `after_destroy` callbacks on +your models _unless_ absolutely required and only when approved by database +specialists. For example, if each row in a table has a corresponding file on a +file system it may be tempting to add a `after_destroy` hook. This however +introduces non database logic to a model, and means we can no longer rely on +foreign keys to remove the data as this would result in the filesystem data +being left behind. In such a case you should use a service class instead that +takes care of removing non database data. diff --git a/doc/development/i18n_guide.md b/doc/development/i18n_guide.md index 44eca68aaca..6dcf8e7b20c 100644 --- a/doc/development/i18n_guide.md +++ b/doc/development/i18n_guide.md @@ -7,6 +7,17 @@ For working with internationalization (i18n) we use tool for this task and we have a lot of applications that will help us to work with it. +<<<<<<< HEAD +======= +## Setting up GitLab Development Kit (GDK) + +In order to be able to work on the [GitLab Community Edition](https://gitlab.com/gitlab-org/gitlab-ce) project we must download and +configure it through [GDK](https://gitlab.com/gitlab-org/gitlab-development-kit), we can do it by following this [guide](https://gitlab.com/gitlab-org/gitlab-development-kit/blob/master/doc/set-up-gdk.md). + +Once we have the GitLab project ready we can start working on the +translation of the project. + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 ## Tools We use a couple of gems: @@ -211,9 +222,17 @@ Let's suppose you want to add translations for a new language, let's say French. you just need to separate the region with an underscore (`_`). For example: ```sh +<<<<<<< HEAD bundle exec rake gettext:add_language[en_gb] ``` +======= + bundle exec rake gettext:add_language[en_GB] + ``` + + Please note that you need to specify the region part in capitals. + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 1. Now that the language is added, a new directory has been created under the path: `locale/fr/`. You can now start using your PO editor to edit the PO file located in: `locale/fr/gitlab.edit.po`. @@ -223,8 +242,12 @@ Let's suppose you want to add translations for a new language, let's say French. containing the translations: ```sh +<<<<<<< HEAD bundle exec rake gettext:pack bundle exec rake gettext:po_to_json +======= + bundle exec rake gettext:compile +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 ``` 1. In order to see the translated content we need to change our preferred language diff --git a/doc/development/img/trigger_ss1.png b/doc/development/img/trigger_ss1.png Binary files differnew file mode 100644 index 00000000000..ccff1009a25 --- /dev/null +++ b/doc/development/img/trigger_ss1.png diff --git a/doc/development/img/trigger_ss2.png b/doc/development/img/trigger_ss2.png Binary files differnew file mode 100644 index 00000000000..94dfd048793 --- /dev/null +++ b/doc/development/img/trigger_ss2.png diff --git a/doc/development/polymorphic_associations.md b/doc/development/polymorphic_associations.md new file mode 100644 index 00000000000..d63b9fb115f --- /dev/null +++ b/doc/development/polymorphic_associations.md @@ -0,0 +1,146 @@ +# Polymorphic Associations + +**Summary:** always use separate tables instead of polymorphic associations. + +Rails makes it possible to define so called "polymorphic associations". This +usually works by adding two columns to a table: a target type column, and a +target id. For example, at the time of writing we have such a setup for +`members` with the following columns: + +* `source_type`: a string defining the model to use, can be either `Project` or + `Namespace`. +* `source_id`: the ID of the row to retrieve based on `source_type`. For + example, when `source_type` is `Project` then `source_id` will contain a + project ID. + +While such a setup may appear to be useful, it comes with many drawbacks; enough +that you should avoid this at all costs. + +## Space Wasted + +Because this setup relies on string values to determine the model to use it will +end up wasting a lot of space. For example, for `Project` and `Namespace` the +maximum size is 9 bytes, plus 1 extra byte for every string when using +PostgreSQL. While this may only be 10 bytes per row, given enough tables and +rows using such a setup we can end up wasting quite a bit of disk space and +memory (for any indexes). + +## Indexes + +Because our associations are broken up into two columns this may result in +requiring composite indexes for queries to be performed efficiently. While +composite indexes are not wrong at all, they can be tricky to set up as the +ordering of columns in these indexes is important to ensure optimal performance. + +## Consistency + +One really big problem with polymorphic associations is being unable to enforce +data consistency on the database level using foreign keys. For consistency to be +enforced on the database level one would have to write their own foreign key +logic to support polymorphic associations. + +Enforcing consistency on the database level is absolutely crucial for +maintaining a healthy environment, and thus is another reason to avoid +polymorphic associations. + +## Query Overhead + +When using polymorphic associations you always need to filter using both +columns. For example, you may end up writing a query like this: + +```sql +SELECT * +FROM members +WHERE source_type = 'Project' +AND source_id = 13083; +``` + +Here PostgreSQL can perform the query quite efficiently if both columns are +indexed, but as the query gets more complex it may not be able to use these +indexes efficiently. + +## Mixed Responsibilities + +Similar to functions and classes a table should have a single responsibility: +storing data with a certain set of pre-defined columns. When using polymorphic +associations you are instead storing different types of data (possibly with +different columns set) in the same table. + +## The Solution + +Fortunately there is a very simple solution to these problems: simply use a +separate table for every type you would otherwise store in the same table. Using +a separate table allows you to use everything a database may provide to ensure +consistency and query data efficiently, without any additional application logic +being necessary. + +Let's say you have a `members` table storing both approved and pending members, +for both projects and groups, and the pending state is determined by the column +`requested_at` being set or not. Schema wise such a setup can lead to various +columns only being set for certain rows, wasting space. It's also possible that +certain indexes will only be set for certain rows, again wasting space. Finally, +querying such a table requires less than ideal queries. For example: + +```sql +SELECT * +FROM members +WHERE requested_at IS NULL +AND source_type = 'GroupMember' +AND source_id = 4 +``` + +Instead such a table should be broken up into separate tables. For example, you +may end up with 4 tables in this case: + +* project_members +* group_members +* pending_project_members +* pending_group_members + +This makes querying data trivial. For example, to get the members of a group +you'd run: + +```sql +SELECT * +FROM group_members +WHERE group_id = 4 +``` + +To get all the pending members of a group in turn you'd run: + +```sql +SELECT * +FROM pending_group_members +WHERE group_id = 4 +``` + +If you want to get both you can use a UNION, though you need to be explicit +about what columns you want to SELECT as otherwise the result set will use the +columns of the first query. For example: + +```sql +SELECT id, 'Group' AS target_type, group_id AS target_id +FROM group_members + +UNION ALL + +SELECT id, 'Project' AS target_type, project_id AS target_id +FROM project_members +``` + +The above example is perhaps a bit silly, but it shows that there's nothing +stopping you from merging the data together and presenting it on the same page. +Selecting columns explicitly can also speed up queries as the database has to do +less work to get the data (compared to selecting all columns, even ones you're +not using). + +Our schema also becomes easier. No longer do we need to both store and index the +`source_type` column, we can define foreign keys easily, and we don't need to +filter rows using the `IS NULL` condition. + +To summarize: using separate tables allows us to use foreign keys effectively, +create indexes only where necessary, conserve space, query data more +efficiently, and scale these tables more easily (e.g. by storing them on +separate disks). A nice side effect of this is that code can also become easier +as you won't end up with a single model having to handle different kinds of +data. diff --git a/doc/development/serializing_data.md b/doc/development/serializing_data.md new file mode 100644 index 00000000000..2b56f48bc44 --- /dev/null +++ b/doc/development/serializing_data.md @@ -0,0 +1,84 @@ +# Serializing Data + +**Summary:** don't store serialized data in the database, use separate columns +and/or tables instead. + +Rails makes it possible to store serialized data in JSON, YAML or other formats. +Such a field can be defined as follows: + +```ruby +class Issue < ActiveRecord::Model + serialize :custom_fields +end +``` + +While it may be tempting to store serialized data in the database there are many +problems with this. This document will outline these problems and provide an +alternative. + +## Serialized Data Is Less Powerful + +When using a relational database you have the ability to query individual +fields, change the schema, index data and so forth. When you use serialized data +all of that becomes either very difficult or downright impossible. While +PostgreSQL does offer the ability to query JSON fields it is mostly meant for +very specialized use cases, and not for more general use. If you use YAML in +turn there's no way to query the data at all. + +## Waste Of Space + +Storing serialized data such as JSON or YAML will end up wasting a lot of space. +This is because these formats often include additional characters (e.g. double +quotes or newlines) besides the data that you are storing. + +## Difficult To Manage + +There comes a time where you will need to add a new field to the serialized +data, or change an existing one. Using serialized data this becomes difficult +and very time consuming as the only way of doing so is to re-write all the +stored values. To do so you would have to: + +1. Retrieve the data +1. Parse it into a Ruby structure +1. Mutate it +1. Serialize it back to a String +1. Store it in the database + +On the other hand, if one were to use regular columns adding a column would be +as easy as this: + +```sql +ALTER TABLE table_name ADD COLUMN column_name type; +``` + +Such a query would take very little to no time and would immediately apply to +all rows, without having to re-write large JSON or YAML structures. + +Finally, there comes a time when the JSON or YAML structure is no longer +sufficient and you need to migrate away from it. When storing only a few rows +this may not be a problem, but when storing millions of rows such a migration +can easily take hours or even days to complete. + +## Relational Databases Are Not Document Stores + +When storing data as JSON or YAML you're essentially using your database as if +it were a document store (e.g. MongoDB), except you're not using any of the +powerful features provided by a typical RDBMS _nor_ are you using any of the +features provided by a typical document store (e.g. the ability to index fields +of documents with variable fields). In other words, it's a waste. + +## Consistent Fields + +One argument sometimes made in favour of serialized data is having to store +widely varying fields and values. Sometimes this is truly the case, and then +perhaps it might make sense to use serialized data. However, in 99% of the cases +the fields and types stored tend to be the same for every row. Even if there is +a slight difference you can still use separate columns and just not set the ones +you don't need. + +## The Solution + +The solution is very simple: just use separate columns and/or separate tables. +This will allow you to use all the features provided by your database, it will +make it easier to manage and migrate the data, you'll conserve space, you can +index the data efficiently and so forth. diff --git a/doc/development/single_table_inheritance.md b/doc/development/single_table_inheritance.md new file mode 100644 index 00000000000..27c3c4f3199 --- /dev/null +++ b/doc/development/single_table_inheritance.md @@ -0,0 +1,18 @@ +# Single Table Inheritance + +**Summary:** don't use Single Table Inheritance (STI), use separate tables +instead. + +Rails makes it possible to have multiple models stored in the same table and map +these rows to the correct models using a `type` column. This can be used to for +example store two different types of SSH keys in the same table. + +While tempting to use one should avoid this at all costs for the same reasons as +outlined in the document ["Polymorphic Associations"](polymorphic_associations.md). + +## Solution + +The solution is very simple: just use a separate table for every type you'd +otherwise store in the same table. For example, instead of having a `keys` table +with `type` set to either `Key` or `DeployKey` you'd have two separate tables: +`keys` and `deploy_keys`. diff --git a/doc/development/ux_guide/basics.md b/doc/development/ux_guide/basics.md index 259b214bd59..a436e9b1948 100644 --- a/doc/development/ux_guide/basics.md +++ b/doc/development/ux_guide/basics.md @@ -22,7 +22,7 @@ GitLab's main typeface used throughout the UI is **Source Sans Pro**. We support ### Monospace typeface -This is the typeface used for code blocks. GitLab uses the OS default font. +This is the typeface used for code blocks and references to commits, branches, and tags (`.commit-sha` or `.ref-name`). GitLab uses the OS default font. - **Menlo** (Mac) - **Consolas** (Windows) - **Liberation Mono** (Linux) diff --git a/doc/development/what_requires_downtime.md b/doc/development/what_requires_downtime.md index 8da6ad684f5..c4830322fa8 100644 --- a/doc/development/what_requires_downtime.md +++ b/doc/development/what_requires_downtime.md @@ -139,6 +139,8 @@ Adding or removing a NOT NULL clause (or another constraint) can typically be done without requiring downtime. However, this does require that any application changes are deployed _first_. Thus, changing the constraints of a column should happen in a post-deployment migration. +NOTE: Avoid using `change_column` as it produces inefficient query because it re-defines +the whole column type. For example, to add a NOT NULL constraint, prefer `change_column_null ` ## Changing Column Types diff --git a/doc/development/writing_documentation.md b/doc/development/writing_documentation.md index 2814c18e0b6..eac9ec2a470 100644 --- a/doc/development/writing_documentation.md +++ b/doc/development/writing_documentation.md @@ -52,11 +52,13 @@ Every **Technical Article** contains, in the very beginning, a blockquote with t - A reference to the **type of article** (user guide, admin guide, tech overview, tutorial) - A reference to the **knowledge level** expected from the reader to be able to follow through (beginner, intermediate, advanced) - A reference to the **author's name** and **GitLab.com handle** +- A reference of the **publication date** ```md -> **Type:** tutorial || +> **Article [Type](../../development/writing_documentation.html#types-of-technical-articles):** tutorial || > **Level:** intermediary || -> **Author:** [Name Surname](https://gitlab.com/username) +> **Author:** [Name Surname](https://gitlab.com/username) || +> **Publication date:** AAAA/MM/DD ``` #### Technical Articles - Writing Method @@ -76,14 +78,21 @@ Currently GitLab docs use Redcarpet as [markdown](../user/markdown.md) engine, b We try to treat documentation as code, thus have implemented some testing. Currently, the following tests are in place: -1. `docs:check:links`: Check that all internal (relative) links work correctly -1. `docs:check:apilint`: Check that the API docs follow some conventions +1. `docs lint`: Check that all internal (relative) links work correctly and + that all cURL examples in API docs use the full switches. If your contribution contains **only** documentation changes, you can speed up -the CI process by prepending to the name of your branch: `docs/`. For example, -a valid name would be `docs/update-api-issues` and it will run only the docs -tests. If the name is `docs-update-api-issues`, the whole test suite will run -(including docs). +the CI process by following some branch naming conventions. You have three +choices: + +| Branch name | Valid example | +| ----------- | ------------- | +| Starting with `docs/` | `docs/update-api-issues` | +| Starting with `docs-` | `docs-update-api-issues` | +| Ending in `-docs` | `123-update-api-issues-docs` | + +If your branch name matches any of the above, it will run only the docs +tests. If it doesn't, the whole test suite will run (including docs). --- diff --git a/doc/install/README.md b/doc/install/README.md index 3bf7923a9ee..a40fa00d14e 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -18,8 +18,11 @@ the hardware requirements. Useful for unsupported systems like *BSD. For an overview of the directory structure, read the [structure documentation](structure.md). - [Docker](https://docs.gitlab.com/omnibus/docker/) - Install GitLab using Docker. +<<<<<<< HEAD - [Installation on Google Cloud Platform](google_cloud_platform/index.md) - Install GitLab on Google Cloud Platform using our official image. +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - [Installing in Kubernetes](kubernetes/index.md) - Install GitLab into a Kubernetes Cluster using our official Helm Chart Repository. - Testing only! [DigitalOcean and Docker Machine](digitaloceandocker.md) - diff --git a/doc/install/database_mysql.md b/doc/install/database_mysql.md index da2dac23c6a..9a171d34671 100644 --- a/doc/install/database_mysql.md +++ b/doc/install/database_mysql.md @@ -281,5 +281,5 @@ GitLab database to `longtext` columns, which can persist values of up to 4GB Details can be found in the [PostgreSQL][postgres-text-type] and [MySQL][mysql-text-types] manuals. -[postgres-text-type]: http://www.postgresql.org/docs/9.1/static/datatype-character.html +[postgres-text-type]: http://www.postgresql.org/docs/9.2/static/datatype-character.html [mysql-text-types]: http://dev.mysql.com/doc/refman/5.7/en/string-type-overview.html diff --git a/doc/install/google_cloud_platform/index.md b/doc/install/google_cloud_platform/index.md index 26506111548..35220119e9b 100644 --- a/doc/install/google_cloud_platform/index.md +++ b/doc/install/google_cloud_platform/index.md @@ -2,6 +2,10 @@  +>**Important note:** +GitLab has no official images in Google Cloud Platform yet. This guide serves +as a template for when the GitLab VM will be available. + The fastest way to get started on [Google Cloud Platform (GCP)][gcp] is through the [Google Cloud Launcher][launcher] program. diff --git a/doc/install/installation.md b/doc/install/installation.md index 5615b2a534b..84af6432889 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -109,14 +109,19 @@ Then select 'Internet Site' and press enter to confirm the hostname. ## 2. Ruby -**Note:** The current supported Ruby version is 2.3.x. GitLab 9.0 dropped support -for Ruby 2.1.x. +The Ruby interpreter is required to run GitLab. + +**Note:** The current supported Ruby (MRI) version is 2.3.x. GitLab 9.0 dropped +support for Ruby 2.1.x. 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, GitLab Shell is called from OpenSSH, and having a version manager can prevent pushing and pulling over SSH. Version managers are not supported and we strongly -advise everyone to follow the instructions below to use a system Ruby. +advise everyone to follow the instructions below to use a system Ruby. + +Linux distributions generally have older versions of Ruby available, so these +instructions are designed to install Ruby from the official source code. Remove the old Ruby 1.8 if present: @@ -132,26 +137,25 @@ Download Ruby and compile it: make sudo make install -Install the Bundler Gem: +Then install the Bundler Gem: sudo gem install bundler --no-ri --no-rdoc ## 3. Go -Since GitLab 8.0, Git HTTP requests are handled by gitlab-workhorse (formerly -gitlab-git-http-server). This is a small daemon written in Go. To install -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 +Since GitLab 8.0, GitLab has several daemons written in Go. To install +GitLab 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). # Remove former Go installation folder sudo rm -rf /usr/local/go - 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 + curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz + echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \ + sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ - rm go1.5.3.linux-amd64.tar.gz + rm go1.8.3.linux-amd64.tar.gz ## 4. Node @@ -161,7 +165,7 @@ In many distros the versions provided by the official package repositories are out of date, so we'll need to install through the following commands: # install node v7.x - curl --location https://deb.nodesource.com/setup_7.x | bash - + curl --location https://deb.nodesource.com/setup_7.x | sudo bash - sudo apt-get install -y nodejs # install yarn @@ -180,7 +184,8 @@ Create a `git` user for GitLab: We recommend using a PostgreSQL database. For MySQL check the [MySQL setup guide](database_mysql.md). -> **Note**: because we need to make use of extensions you need at least pgsql 9.1. +> **Note**: because we need to make use of extensions and concurrent index removal, +you need at least PostgreSQL 9.2. 1. Install the database packages: @@ -464,10 +469,6 @@ Make GitLab start on boot: ### Install Gitaly -As of GitLab 9.1 Gitaly is an **optional** component. Its -configuration is still changing regularly. It is OK to wait -with setting up Gitaly until you upgrade to GitLab 9.2 or later. - # Fetch Gitaly source with Git and compile with Go sudo -u git -H bundle exec rake "gitlab:gitaly:install[/home/git/gitaly]" RAILS_ENV=production @@ -485,16 +486,6 @@ Next, make sure gitaly configured: cd /home/git/gitaly sudo -u git -H editor config.toml - # Enable Gitaly in the init script - echo 'gitaly_enabled=true' | sudo tee -a /etc/default/gitlab - -Next, edit `/home/git/gitlab/config/gitlab.yml` and make sure `enabled: true` in -the `gitaly:` section is uncommented. - - # <- gitlab.yml indentation starts here - gitaly: - enabled: true - For more information about configuring Gitaly see [doc/administration/gitaly](../administration/gitaly). @@ -513,6 +504,10 @@ Check if GitLab and its environment are configured correctly: sudo -u git -H yarn install --production --pure-lockfile sudo -u git -H bundle exec rake gitlab:assets:compile RAILS_ENV=production NODE_ENV=production +### Compile GetText PO files + + sudo -u git -H bundle exec rake gettext:compile RAILS_ENV=production + ### Start Your GitLab Instance sudo service gitlab start diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md index 2d7edbe16e4..b2dd116b09a 100644 --- a/doc/install/kubernetes/gitlab_chart.md +++ b/doc/install/kubernetes/gitlab_chart.md @@ -1,4 +1,10 @@ # GitLab Helm Chart +<<<<<<< HEAD +======= +> Officially supported cloud providers are Google Container Service and Azure Container Service. + +> Officially supported schedulers are Kubernetes and Terraform. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 The `gitlab` Helm chart deploys GitLab into your Kubernetes cluster. @@ -14,7 +20,11 @@ This chart includes the following: ## Prerequisites +<<<<<<< HEAD - _At least_ 3 GB of RAM available on your cluster, in chunks of 1 GB +======= +- _At least_ 3 GB of RAM available on your cluster, in chunks of 1 GB. 41GB of storage and 2 CPU are also required. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - Kubernetes 1.4+ with Beta APIs enabled - [Persistent Volume](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) provisioner support in the underlying infrastructure - The ability to point a DNS entry or URL at your GitLab install @@ -203,8 +213,49 @@ its class in an annotation. >**Note:** The Ingress alone doesn't expose GitLab externally. You need to have a Ingress controller setup to do that. +<<<<<<< HEAD Setting up an Ingress controller can be as simple as installing the `nginx-ingress` helm chart. But be sure to read the [documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md) +======= +Setting up an Ingress controller can be done by installing the `nginx-ingress` helm chart. But be sure +to read the [documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md). +>**Note:** +If you would like to use the Registry, you will also need to ensure your Ingress supports a [sufficiently large request size](http://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size). + +#### Preserving Source IPs + +If you are using the `LoadBalancer` serviceType you may run into issues where user IP addresses in the GitLab +logs, and used in abuse throttling are not accurate. This is due to how Kubernetes uses source NATing on cluster nodes without endpoints. + +See the [Kubernetes documentation](https://kubernetes.io/docs/tutorials/services/source-ip/#source-ip-for-services-with-typeloadbalancer) for more information. + +To fix this you can add the following service annotation to your `values.yaml` + +```yaml +## For minikube, set this to NodePort, elsewhere use LoadBalancer +## ref: http://kubernetes.io/docs/user-guide/services/#publishing-services---service-types +## +serviceType: LoadBalancer + +## Optional annotations for gitlab service. +serviceAnnotations: + service.beta.kubernetes.io/external-traffic: "OnlyLocal" +``` + +>**Note:** +If you are using the ingress routing, you will likely also need to specify the annotation on the service for the ingress +controller. For `nginx-ingress` you can check the +[configuration documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md#configuration) +on how to add the annotation to the `controller.service.annotations` array. + +>**Note:** +When using the `nginx-ingress` controller on Google Container Engine (GKE), and using the `external-traffic` annotation, +you will need to additionally set the `controller.kind` to be DaemonSet. Otherwise only pods running on the same node +as the nginx controller will be able to reach GitLab. This may result in pods within your cluster not being able to reach GitLab. +See the [Kubernetes documentation](https://kubernetes.io/docs/tutorials/services/source-ip/#source-ip-for-services-with-typeloadbalancer) and +[nginx-ingress configuration documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md#configuration) +for more information. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 ### External database @@ -387,6 +438,10 @@ ingress: ``` ## Installing GitLab using the Helm Chart +<<<<<<< HEAD +======= +> You may see a temporary error message `SchedulerPredicates failed due to PersistentVolumeClaim is not bound` while storage provisions. Once the storage provisions, the pods will automatically restart. This may take a couple minutes depending on your cloud provider. If the error persists, please review the [prerequisites](#prerequisites) to ensure you have enough RAM, CPU, and storage. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 Once you [have configured](#configuration) GitLab in your `values.yml` file, run the following: diff --git a/doc/install/kubernetes/gitlab_runner_chart.md b/doc/install/kubernetes/gitlab_runner_chart.md index dbd9ae3f70c..92d08fe6bff 100644 --- a/doc/install/kubernetes/gitlab_runner_chart.md +++ b/doc/install/kubernetes/gitlab_runner_chart.md @@ -1,4 +1,10 @@ # GitLab Runner Helm Chart +<<<<<<< HEAD +======= +> Officially supported cloud providers are Google Container Service and Azure Container Service. + +> Officially supported schedulers are Kubernetes and Terraform. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 The `gitlab-runner` Helm chart deploys a GitLab Runner instance into your Kubernetes cluster. @@ -138,7 +144,11 @@ Once you [have configured](#configuration) GitLab Runner in your `values.yml` fi run the following: ```bash +<<<<<<< HEAD helm install --namepace <NAMEPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> gitlab/gitlab-runner +======= +helm install --namespace <NAMESPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> gitlab/gitlab-runner +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 ``` - `<NAMESPACE>` is the Kubernetes namespace where you want to install the GitLab Runner. @@ -150,7 +160,11 @@ helm install --namepace <NAMEPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> Once your GitLab Runner Chart is installed, configuration changes and chart updates should we done using `helm upgrade` ```bash +<<<<<<< HEAD helm upgrade --namepace <NAMEPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab-runner +======= +helm upgrade --namespace <NAMESPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab-runner +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 ``` Where: diff --git a/doc/install/kubernetes/index.md b/doc/install/kubernetes/index.md index db0430fc27b..97cb1470eb4 100644 --- a/doc/install/kubernetes/index.md +++ b/doc/install/kubernetes/index.md @@ -1,4 +1,11 @@ +<<<<<<< HEAD # Installing GitLab in Kubernetes +======= +# Installing GitLab on Kubernetes +> Officially supported cloud providers are Google Container Service and Azure Container Service. + +> Officially supported schedulers are Kubernetes and Terraform. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 The easiest method to deploy GitLab in [Kubernetes](https://kubernetes.io/) is to take advantage of the official GitLab Helm charts. [Helm] is a package diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 35586091f74..5338ccb9d3a 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -122,15 +122,25 @@ To change the Unicorn workers when you have the Omnibus package please see [the We currently support the following databases: -- PostgreSQL (recommended) +- PostgreSQL - MySQL/MariaDB -If you want to run the database separately, expect a size of about 1 MB per user. +We _highly_ recommend the use of PostgreSQL instead of MySQL/MariaDB as not all +features of GitLab may work with MySQL/MariaDB. For example, MySQL does not have +the right features to support nested groups in an efficient manner; see +<https://gitlab.com/gitlab-org/gitlab-ce/issues/30472> for more information +about this. Existing users using GitLab with MySQL/MariaDB are advised to +migrate to PostgreSQL instead. + +The server running the database should have _at least_ 5-10 GB of storage +available, though the exact requirements depend on the size of the GitLab +installation (e.g. the number of users, projects, etc). ### PostgreSQL Requirements -As of GitLab 9.0, PostgreSQL 9.6 is recommended. Lower versions of PostgreSQL -may work but primary testing and developement takes place using PostgreSQL 9.6. +As of GitLab 9.3, PostgreSQL 9.2 or newer is required, and earlier versions are +not supported. We highly recommend users to use at least PostgreSQL 9.6 as this +is the PostgreSQL version used for development and testing. Users using PostgreSQL must ensure the `pg_trgm` extension is loaded into every GitLab database. This extension can be enabled (using a PostgreSQL super user) @@ -165,4 +175,4 @@ about it, check the [Prometheus documentation](../administration/monitoring/prom We support the current and the previous major release of Firefox, Chrome/Chromium, Safari and Microsoft browsers (Microsoft Edge and Internet Explorer 11). -Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version.
\ No newline at end of file +Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version. diff --git a/doc/integration/github.md b/doc/integration/github.md index 4b0d33334bd..b0d67db8b59 100644 --- a/doc/integration/github.md +++ b/doc/integration/github.md @@ -103,12 +103,59 @@ GitHub will generate an application ID and secret key for you to use. 1. Save the configuration file. -1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you +1. [Reconfigure GitLab][] or [restart GitLab][] for the changes to take effect if you installed GitLab via Omnibus or from source respectively. On the sign in page there should now be a GitHub icon below the regular sign in form. Click the icon to begin the authentication process. GitHub 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. -[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +### GitHub Enterprise with Self-Signed Certificate + +If you are attempting to import projects from GitHub Enterprise with a self-signed +certificate and the imports are failing, you will need to disable SSL verification. +It should be disabled by adding `verify_ssl` to `false` in the provider configuration +and changing the global Git `sslVerify` option to `false` in the GitLab server. + +For omnibus package: + +```ruby + gitlab_rails['omniauth_providers'] = [ + { + "name" => "github", + "app_id" => "YOUR_APP_ID", + "app_secret" => "YOUR_APP_SECRET", + "url" => "https://github.com/", + "verify_ssl" => false, + "args" => { "scope" => "user:email" } + } + ] +``` + +You will also need to disable Git SSL verification on the server hosting GitLab. + +```ruby +omnibus_gitconfig['system'] = { "http" => ["sslVerify = false"] } +``` + +For installation from source: + +``` + - { name: 'github', app_id: 'YOUR_APP_ID', + app_secret: 'YOUR_APP_SECRET', + url: "https://github.example.com/", + verify_ssl: false, + args: { scope: 'user:email' } } +``` + +You will also need to disable Git SSL verification on the server hosting GitLab. + +``` +$ git config --global http.sslVerify false +``` + +For the changes to take effect, [reconfigure Gitlab] if you installed +via Omnibus, or [restart GitLab] if you installed from source. + +[reconfigure GitLab]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure [restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/saml.md b/doc/integration/saml.md index 2277aa827b7..b5b245c626f 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -201,6 +201,9 @@ Please keep in mind that every sign in attempt will be redirected to the SAML se so you will not be able to sign in using local credentials. Make sure that at least one of the SAML users has admin permissions. +You may also bypass the auto signin feature by browsing to +https://gitlab.example.com/users/sign_in?auto_sign_in=false. + ### `attribute_statements` >**Note:** diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index 5be6053b76e..855f437cd73 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -133,7 +133,7 @@ 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/). GitLab [imports cloud drivers](https://gitlab.com/gitlab-org/gitlab-ce/blob/30f5b9a5b711b46f1065baf755e413ceced5646b/Gemfile#L88) -for AWS, Google, OpenStack Swift and Rackspace as well. A local driver is +for AWS, Google, OpenStack Swift, Rackspace and Aliyun as well. A local driver is [also available](#uploading-to-locally-mounted-shares). For omnibus packages, add the following to `/etc/gitlab/gitlab.rb`: diff --git a/doc/raketasks/user_management.md b/doc/raketasks/user_management.md index 044b104f5c2..3ae46019daf 100644 --- a/doc/raketasks/user_management.md +++ b/doc/raketasks/user_management.md @@ -71,6 +71,85 @@ sudo gitlab-rake gitlab:two_factor:disable_for_all_users bundle exec rake gitlab:two_factor:disable_for_all_users RAILS_ENV=production ``` +## Rotate Two-factor Authentication (2FA) encryption key + +GitLab stores the secret data enabling 2FA to work in an encrypted database +column. The encryption key for this data is known as `otp_key_base`, and is +stored in `config/secrets.yml`. + + +If that file is leaked, but the individual 2FA secrets have not, it's possible +to re-encrypt those secrets with a new encryption key. This allows you to change +the leaked key without forcing all users to change their 2FA details. + +First, look up the old key. This is in the `config/secrets.yml` file, but +**make sure you're working with the production section**. The line you're +interested in will look like this: + +```yaml +production: + otp_key_base: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +``` + +Next, generate a new secret: + +``` +# omnibus-gitlab +sudo gitlab-rake secret + +# installation from source +bundle exec rake secret RAILS_ENV=production +``` + +Now you need to stop the GitLab server, back up the existing secrets file and +update the database: + +``` +# omnibus-gitlab +sudo gitlab-ctl stop +sudo cp config/secrets.yml config/secrets.yml.bak +sudo gitlab-rake gitlab:two_factor:rotate_key:apply filename=backup.csv old_key=<old key> new_key=<new key> + +# installation from source +sudo /etc/init.d/gitlab stop +cp config/secrets.yml config/secrets.yml.bak +bundle exec rake gitlab:two_factor:rotate_key:apply filename=backup.csv old_key=<old key> new_key=<new key> RAILS_ENV=production +``` + +The `<old key>` value can be read from `config/secrets.yml`; `<new key>` was +generated earlier. The **encrypted** values for the user 2FA secrets will be +written to the specified `filename` - you can use this to rollback in case of +error. + +Finally, change `config/secrets.yml` to set `otp_key_base` to `<new key>` and +restart. Again, make sure you're operating in the **production** section. + +``` +# omnibus-gitlab +sudo gitlab-ctl start + +# installation from source +sudo /etc/init.d/gitlab start +``` + +If there are any problems (perhaps using the wrong value for `old_key`), you can +restore your backup of `config/secrets.yml` and rollback the changes: + +``` +# omnibus-gitlab +sudo gitlab-ctl stop +sudo gitlab-rake gitlab:two_factor:rotate_key:rollback filename=backup.csv +sudo cp config/secrets.yml.bak config/secrets.yml +sudo gitlab-ctl start + +# installation from source +sudo /etc/init.d/gitlab start +bundle exec rake gitlab:two_factor:rotate_key:rollback filename=backup.csv RAILS_ENV=production +cp config/secrets.yml.bak config/secrets.yml +sudo /etc/init.d/gitlab start + +``` + ## Clear authentication tokens for all users. Important! Data loss! Clear authentication tokens for all users in the GitLab database. This diff --git a/doc/system_hooks/system_hooks.md b/doc/system_hooks/system_hooks.md index ad5ffc84473..583ec5522fd 100644 --- a/doc/system_hooks/system_hooks.md +++ b/doc/system_hooks/system_hooks.md @@ -266,7 +266,8 @@ X-Gitlab-Event: System Hook ## Push events -Triggered when you push to the repository except when pushing tags. +Triggered when you push to the repository, except when pushing tags. +It generates one event per modified branch. **Request header**: @@ -332,6 +333,7 @@ X-Gitlab-Event: System Hook ## Tag events Triggered when you create (or delete) tags to the repository. +It generates one event per modified tag. **Request header**: @@ -381,3 +383,49 @@ X-Gitlab-Event: System Hook "total_commits_count": 0 } ``` +## Repository Update events + +Triggered only once when you push to the repository (including tags). + +**Request header**: + +``` +X-Gitlab-Event: System Hook +``` + +**Request body:** + +```json +{ + "event_name": "repository_update", + "user_id": 1, + "user_name": "John Smith", + "user_email": "admin@example.com", + "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", + "project_id": 1, + "project": { + "name":"Example", + "description":"", + "web_url":"http://example.com/jsmith/example", + "avatar_url":null, + "git_ssh_url":"git@example.com:jsmith/example.git", + "git_http_url":"http://example.com/jsmith/example.git", + "namespace":"Jsmith", + "visibility_level":0, + "path_with_namespace":"jsmith/example", + "default_branch":"master", + "homepage":"http://example.com/jsmith/example", + "url":"git@example.com:jsmith/example.git", + "ssh_url":"git@example.com:jsmith/example.git", + "http_url":"http://example.com/jsmith/example.git", + }, + "changes": [ + { + "before":"8205ea8d81ce0c6b90fbe8280d118cc9fdad6130", + "after":"4045ea7a3df38697b3730a20fb73c8bed8a3e69e", + "ref":"refs/heads/master" + } + ], + "refs":["refs/heads/master"] +} +``` diff --git a/doc/topics/authentication/index.md b/doc/topics/authentication/index.md index 3e756d96ed2..0c0d482499a 100644 --- a/doc/topics/authentication/index.md +++ b/doc/topics/authentication/index.md @@ -19,7 +19,7 @@ This page gathers all the resources for the topic **Authentication** within GitL - [Enforce Two-factor Authentication (2FA)](../../security/two_factor_authentication.md#enforce-two-factor-authentication-2fa) - **Articles:** - [How to Configure LDAP with GitLab CE](../../articles/how_to_configure_ldap_gitlab_ce/index.md) - - [How to Configure LDAP with GitLab EE](https://docs.gitlab.com/articles/how_to_configure_ldap_gitlab_ee/) + - [How to Configure LDAP with GitLab EE](https://docs.gitlab.com/ee/articles/how_to_configure_ldap_gitlab_ee/) - [Feature Highlight: LDAP Integration](https://about.gitlab.com/2014/07/10/feature-highlight-ldap-sync/) - [Debugging LDAP](https://about.gitlab.com/handbook/support/workflows/ldap/debugging_ldap.html) - **Integrations:** diff --git a/doc/topics/git/index.md b/doc/topics/git/index.md index d13066c9015..604f9375714 100644 --- a/doc/topics/git/index.md +++ b/doc/topics/git/index.md @@ -22,6 +22,7 @@ We've gathered some resources to help you to get the best from Git with GitLab. - [Cherry-picking a commit](../../user/project/merge_requests/cherry_pick_changes.md#cherry-picking-a-commit) - [Squashing commits](../../workflow/gitlab_flow.md#squashing-commits-with-rebase) - **Articles:** + - [How to install Git](../../articles/how_to_install_git/index.md) - [Git Tips & Tricks](https://about.gitlab.com/2016/12/08/git-tips-and-tricks/) - [Eight Tips to help you work better with Git](https://about.gitlab.com/2015/02/19/8-tips-to-help-you-work-better-with-git/) - **Presentations:** diff --git a/doc/university/README.md b/doc/university/README.md index c1661f0b52b..399d54bcf23 100644 --- a/doc/university/README.md +++ b/doc/university/README.md @@ -65,6 +65,7 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project 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. [The GitLab Book Club](bookclub/index.md) +1. [GitLab Resources](https://about.gitlab.com/resources/) #### 1.7 Community and Support diff --git a/doc/university/high-availability/aws/README.md b/doc/university/high-availability/aws/README.md index 088f1cd7290..6b8f3cd3d1d 100644 --- a/doc/university/high-availability/aws/README.md +++ b/doc/university/high-availability/aws/README.md @@ -159,19 +159,21 @@ subnet and security group and *** -## Elastic File System +## Network 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. +GitLab requires a shared filesystem such as NFS. The file share(s) will be +mounted on all application servers. There are a variety of ways to build an +NFS server on AWS. -
 +One option is to use a third-party AMI that offers NFS as a service. A [search +for 'NFS' in the AWS Marketplace](https://aws.amazon.com/marketplace/search/results?x=0&y=0&searchTerms=NFS&page=1&ref_=nav_search_box) +shows options such as NetApp, SoftNAS and others. -To actually mount and install the NFS client we'll use the User Data -section when adding our Launch Configuration. +Another option is to build a simple NFS server using a vanilla Linux server backed +by AWS Elastic Block Storage (EBS). + +> **Note:** GitLab does not recommend using AWS Elastic File System (EFS). See + details in [High Availability NFS documentation](../../../administration/high_availability/nfs.md#aws-elastic-file-system) *** diff --git a/doc/update/9.0-to-9.1.md b/doc/update/9.0-to-9.1.md index 2b582d4eefd..2d597894517 100644 --- a/doc/update/9.0-to-9.1.md +++ b/doc/update/9.0-to-9.1.md @@ -104,7 +104,6 @@ cd /home/git/gitlab-shell sudo -u git -H git fetch --all --tags sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION) -sudo -u git -H bin/compile ``` ### 7. Update gitlab-workhorse diff --git a/doc/update/9.2-to-9.3.md b/doc/update/9.2-to-9.3.md new file mode 100644 index 00000000000..0c32e4db53f --- /dev/null +++ b/doc/update/9.2-to-9.3.md @@ -0,0 +1,305 @@ +# From 9.2 to 9.3 + +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 + +```bash +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 + +NOTE: GitLab 9.0 and higher only support Ruby 2.3.x and dropped support for Ruby 2.1.x. Be +sure to upgrade your interpreter if necessary. + +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.3.tar.gz +echo '1014ee699071aa2ddd501907d18cbe15399c997d ruby-2.3.3.tar.gz' | shasum -c - && tar xzf ruby-2.3.3.tar.gz +cd ruby-2.3.3 +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Update Node + +GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets and +it has a minimum requirement of node v4.3.0. + +You can check which version you are running with `node -v`. If you are running +a version older than `v4.3.0` you will need to update to a newer version. You +can find instructions to install from community maintained packages or compile +from source at the nodejs.org website. + +<https://nodejs.org/en/download/> + + +Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage +JavaScript dependencies. + +```bash +curl --location https://yarnpkg.com/install.sh | bash - +``` + +More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install). + +### 5. Update Go + +NOTE: GitLab 9.3 and higher only supports Go 1.8.3 and dropped support for Go 1.5.x through 1.7.x. Be +sure to upgrade your installation if necessary + +You can check which version you are running with `go version`. + +Download and install Go: + +```bash +# Remove former Go installation folder +sudo rm -rf /usr/local/go + +curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz +echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \ + sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz +sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ +rm go1.8.3.linux-amd64.tar.gz +``` + +### 6. Get latest code + +```bash +cd /home/git/gitlab + +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 +cd /home/git/gitlab + +sudo -u git -H git checkout 9-3-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 9-3-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 v$(</home/git/gitlab/GITLAB_SHELL_VERSION) +sudo -u git -H bin/compile +``` + +### 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. GitLab-Workhorse uses [GNU Make](https://www.gnu.org/software/make/). +If you are not using Linux you may have to run `gmake` instead of +`make` below. + +```bash +cd /home/git/gitlab-workhorse + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION) +sudo -u git -H make +``` + +### 7. Update Gitaly + +If you have not yet set up Gitaly then follow [Gitaly section of the installation +guide](../install/installation.md#install-gitaly). + +#### Compile Gitaly + +```shell +cd /home/git/gitaly +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION) +sudo -u git -H make +``` + +### 10. Update configuration files + +#### New configuration options for `gitlab.yml` + +There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +cd /home/git/gitlab + +git diff origin/9-2-stable:config/gitlab.yml.example origin/9-3-stable:config/gitlab.yml.example +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +cd /home/git/gitlab + +# For HTTPS configurations +git diff origin/9-2-stable:lib/support/nginx/gitlab-ssl origin/9-3-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/9-2-stable:lib/support/nginx/gitlab origin/9-3-stable:lib/support/nginx/gitlab +``` + +If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx +configuration as GitLab application no longer handles setting it. + +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/9-3-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/9-2-stable/config/initializers/smtp_settings.rb.sample#L13 + +#### Init script + +There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`: + +```sh +cd /home/git/gitlab + +git diff origin/9-2-stable:lib/support/init.d/gitlab.default.example origin/9-3-stable:lib/support/init.d/gitlab.default.example +``` + +Ensure you're still up-to-date with the latest init script changes: + +```bash +cd /home/git/gitlab + +sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab +``` + +For Ubuntu 16.04.1 LTS: + +```bash +sudo systemctl daemon-reload +``` + +### 11. 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 + +# Update node dependencies and recompile assets +sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production + +# Clean up cache +sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production +``` + +**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md). + +### 12. Start application + +```bash +sudo service gitlab start +sudo service nginx restart +``` + +### 13. Check application status + +Check if GitLab and its environment are configured correctly: + +```bash +cd /home/git/gitlab + +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: + +```bash +cd /home/git/gitlab + +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 (9.2) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 9.1 to 9.2](9.1-to-9.2.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. + +[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-3-stable/config/gitlab.yml.example +[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-3-stable/lib/support/init.d/gitlab.default.example diff --git a/doc/user/admin_area/settings/usage_statistics.md b/doc/user/admin_area/settings/usage_statistics.md index 375e7f08e8b..f3745d0efa7 100644 --- a/doc/user/admin_area/settings/usage_statistics.md +++ b/doc/user/admin_area/settings/usage_statistics.md @@ -9,7 +9,7 @@ All statistics are opt-out, you can disable them from the admin panel. GitLab can inform you when an update is available and the importance of it. -No information other than the GitLab version and the instance's domain name +No information other than the GitLab version and the instance's hostname (through the HTTP referer) are collected. In the **Overview** tab you can see if your GitLab version is up to date. There @@ -38,7 +38,7 @@ You can view the exact JSON payload in the administration panel. ### Deactivate the usage ping -By default, usage ping is opt-out. If you want to deactivate this feature, go to +The usage ping is opt-out. If you want to deactivate this feature, go to the Settings page of your administration panel and uncheck the Usage ping checkbox. diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md index ffbc5ca4827..0074d9fa5ed 100644 --- a/doc/user/group/subgroups/index.md +++ b/doc/user/group/subgroups/index.md @@ -80,7 +80,11 @@ structure. - You need to be an Owner of a group in order to be able to create a subgroup. For more information check the [permissions table][permissions]. - For a list of words that are not allowed to be used as group names see the +<<<<<<< HEAD [`regex.rb` file][reserved] under the `TOP_LEVEL_ROUTES`, `PROJECT_WILDCARD_ROUTES` and `GROUP_ROUTES` lists: +======= + [`path_regex.rb` file][reserved] under the `TOP_LEVEL_ROUTES`, `PROJECT_WILDCARD_ROUTES` and `GROUP_ROUTES` lists: +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - `TOP_LEVEL_ROUTES`: are names that are reserved as usernames or top level groups - `PROJECT_WILDCARD_ROUTES`: are names that are reserved for child groups or projects. - `GROUP_ROUTES`: are names that are reserved for all groups or projects. @@ -172,4 +176,8 @@ Here's a list of what you can't do with subgroups: [ce-2772]: https://gitlab.com/gitlab-org/gitlab-ce/issues/2772 [permissions]: ../../permissions.md#group +<<<<<<< HEAD [reserved]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/regex.rb +======= +[reserved]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/path_regex.rb +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 637967510f3..3fda47b9e34 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -126,7 +126,7 @@ which visibility level you select on project settings. ## GitLab CI GitLab CI permissions rely on the role the user has in GitLab. There are four -permission levels it total: +permission levels in total: - admin - master @@ -174,7 +174,7 @@ users: | Push container images to other projects | | | | | [^1]: Guest users can only view the confidential issues they created themselves -[^2]: If **Public pipelines** is enabled in **Project Settings > CI/CD Pipelines** +[^2]: If **Public pipelines** is enabled in **Project Settings > Pipelines** [^3]: Not allowed for Guest, Reporter, Developer, Master, or Owner [^4]: Only if user is not external one. [^5]: Only if user is a member of the project. diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md index b5d3b009044..e7596f5c577 100644 --- a/doc/user/profile/account/delete_account.md +++ b/doc/user/profile/account/delete_account.md @@ -5,21 +5,31 @@ ## Associated Records -> Introduced for issues in [GitLab 9.0][ce-7393], and for merge requests, award emoji, notes, and abuse reports in [GitLab 9.1][ce-10467]. +> Introduced for issues in [GitLab 9.0][ce-7393], and for merge requests, award + emoji, notes, and abuse reports in [GitLab 9.1][ce-10467]. + Hard deletion from abuse reports and spam logs was introduced in + [GitLab 9.1][ce-10273], and from the API in [GitLab 9.3][ce-11853]. -When a user account is deleted, not all associated records are deleted with it. Here's a list of things that will not be deleted: +When a user account is deleted, not all associated records are deleted with it. +Here's a list of things that will not be deleted: - Issues that the user created - Merge requests that the user created - Notes that the user created - Abuse reports that the user reported -- Award emoji that the user craeted +- Award emoji that the user created +Instead of being deleted, these records will be moved to a system-wide +"Ghost User", whose sole purpose is to act as a container for such records. -Instead of being deleted, these records will be moved to a system-wide "Ghost User", whose sole purpose is to act as a container for such records. - +When a user is deleted from an abuse report or spam log, these associated +records are not ghosted and will be removed, along with any groups the user +is a sole owner of. Administrators can also request this behaviour when +deleting users from the [API](../../../api/users.md#user-deletion) or the +admin area. [ce-7393]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7393 +[ce-10273]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10273 [ce-10467]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10467 - +[ce-11853]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11853 diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md index e5038835027..f2ad42f21fd 100644 --- a/doc/user/profile/preferences.md +++ b/doc/user/profile/preferences.md @@ -50,15 +50,15 @@ You have 6 options here that you can use for your default dashboard view: - Your groups - Your [Todos] -### Default project view +### Project home page content -The default project view settings allows you to choose what content you want to -see on a project's landing page. +The project home page content setting allows you to choose what content you want to +see on a project’s home page. You can choose between 2 options: - Show the files and the readme (default) -- Show the project's activity +- Show the project’s activity [rouge]: http://rouge.jneen.net/ "Rouge website" [todos]: ../../workflow/todos.md diff --git a/doc/user/project/container_registry.md b/doc/user/project/container_registry.md index 6a2ca7fb428..10c281448a3 100644 --- a/doc/user/project/container_registry.md +++ b/doc/user/project/container_registry.md @@ -95,8 +95,6 @@ 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:** @@ -106,12 +104,14 @@ Make sure that your GitLab Runner is configured to allow building Docker images 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 +## Using with private projects + +If a project is private, credentials will need to be provided for authorization. +The preferred way to do this, is by using personal access tokens, which can be +created under `/profile/personal_access_tokens`. The minimal scope needed is: +`read_registry`. -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. +This feature was introduced in GitLab 9.3. ## Troubleshooting the GitLab Container Registry @@ -257,4 +257,3 @@ 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://docs.gitlab.com/runner/configuration/advanced-configuration.html#using-a-private-container-registry diff --git a/doc/user/project/img/container_registry_panel.png b/doc/user/project/img/container_registry_panel.png Binary files differdeleted file mode 100644 index e4c9ecbb25b..00000000000 --- a/doc/user/project/img/container_registry_panel.png +++ /dev/null diff --git a/doc/user/project/img/project_settings_list.png b/doc/user/project/img/project_settings_list.png Binary files differdeleted file mode 100644 index 0bb761b45c9..00000000000 --- a/doc/user/project/img/project_settings_list.png +++ /dev/null diff --git a/doc/user/project/integrations/img/accessing_integrations.png b/doc/user/project/integrations/img/accessing_integrations.png Binary files differdeleted file mode 100644 index 3b941f64998..00000000000 --- a/doc/user/project/integrations/img/accessing_integrations.png +++ /dev/null diff --git a/doc/user/project/integrations/img/webhook_logs.png b/doc/user/project/integrations/img/webhook_logs.png Binary files differnew file mode 100755 index 00000000000..917068d9398 --- /dev/null +++ b/doc/user/project/integrations/img/webhook_logs.png diff --git a/doc/user/project/integrations/index.md b/doc/user/project/integrations/index.md index 99093ebaed5..e384ed57de9 100644 --- a/doc/user/project/integrations/index.md +++ b/doc/user/project/integrations/index.md @@ -1,10 +1,8 @@ # Project integrations -You can find the available integrations under the **Integrations** page by -navigating to the cog icon in the upper right corner of your project. You need -to have at least [master permission][permissions] on the project. - - +You can find the available integrations under your project's +**Settings ➔ Integrations** page. You need to have at least +[master permission][permissions] on the project. ## Project services diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md index f611029afdc..a048260b033 100644 --- a/doc/user/project/integrations/jira.md +++ b/doc/user/project/integrations/jira.md @@ -97,7 +97,8 @@ in the table below. | Field | Description | | ----- | ----------- | -| `URL` | The base URL to the JIRA project which is being linked to this GitLab project. E.g., `https://jira.example.com`. | +| `Web URL` | The base URL to the JIRA instance web interface which is being linked to this GitLab project. E.g., `https://jira.example.com`. | +| `JIRA API URL` | The base URL to the JIRA instance API. Web URL value will be used if not set. E.g., `https://jira-api.example.com`. | | `Project key` | The short identifier for your JIRA project, all uppercase, e.g., `PROJ`. | | `Username` | The user name created in [configuring JIRA step](#configuring-jira). | | `Password` |The password of the user created in [configuring JIRA step](#configuring-jira). | diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md index 31baea507d7..51989ccaaea 100644 --- a/doc/user/project/integrations/project_services.md +++ b/doc/user/project/integrations/project_services.md @@ -6,18 +6,13 @@ functionality to GitLab. ## Accessing the project services -You can find the available services under the **Integrations** page in your -project's settings. +You can find the available services under your project's +**Settings ➔ Integrations** page. -1. Navigate to the cog icon in the upper right corner of your project. You need - to have at least [master permission][permissions] on the project. +There are more than 20 services to integrate with. Click on the one that you +want to configure. -  - -1. There are more than 20 services to integrate with. Click on the one that you - want to configure. - -  +  Below, you will find a list of the currently supported ones accompanied with comprehensive documentation. diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index e15daa2feae..0517ed3ec18 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -14,11 +14,8 @@ to the webhook URL. Webhooks can be used to update an external issue tracker, trigger CI jobs, update a backup mirror, or even deploy to your production server. -Navigate to the webhooks page by going to the **Integrations** page from your -project's settings which can be found under the wheel icon in the upper right -corner. - - +Navigate to the webhooks page by going to your project's +**Settings ➔ Integrations**. ## Webhook endpoint tips @@ -74,6 +71,7 @@ X-Gitlab-Event: Push Hook "checkout_sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", "user_id": 4, "user_name": "John Smith", + "user_username": "jsmith", "user_email": "john@example.com", "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", "project_id": 15, @@ -1016,6 +1014,22 @@ X-Gitlab-Event: Build Hook } ``` +## Troubleshoot webhooks + +Gitlab stores each perform of the webhook. +You can find records for last 2 days in "Recent Deliveries" section on the edit page of each webhook. + + + +In this section you can see HTTP status code (green for 200-299 codes, red for the others, `internal error` for failed deliveries ), triggered event, a time when the event was called, elapsed time of the request. + +If you need more information about execution, you can click `View details` link. +On this page, you can see data that GitLab sends (request headers and body) and data that it received (response headers and body). + +From this page, you can repeat delivery with the same data by clicking `Resend Request` button. + +>**Note:** If URL or secret token of the webhook were updated, data will be delivered to the new address. + ## Example webhook receiver If you want to see GitLab's webhooks in action for testing purposes you can use diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md index 9598cb801be..368d4343f8e 100644 --- a/doc/user/project/issues/index.md +++ b/doc/user/project/issues/index.md @@ -1,4 +1,8 @@ +<<<<<<< HEAD # GitLab Issues Documentation +======= +# Issues documentation +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 The GitLab Issue Tracker is an advanced and complete tool for tracking the evolution of a new idea or the process @@ -41,13 +45,21 @@ The image bellow illustrates how an issue looks like: Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md). +<<<<<<< HEAD ## New Issue +======= +## New issue +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 Read through the [documentation on creating issues](create_new_issue.md). ## Closing issues +<<<<<<< HEAD Read through the distinct ways to [close issues](closing_issues.md) on GitLab. +======= +Learn distinct ways to [close issues](closing_issues.md) in GitLab. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 ## Create a merge request from an issue @@ -84,7 +96,11 @@ Learn more about them on the [issue templates documentation](../../project/descr Learn more about [crosslinking](crosslinking_issues.md) issues and merge requests. +<<<<<<< HEAD ### GitLab Issue Board +======= +### Issue Board +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 The [GitLab Issue Board](https://about.gitlab.com/features/issueboard/) is a way to enhance your workflow by organizing and prioritizing issues in GitLab. diff --git a/doc/user/project/issues/issues_functionalities.md b/doc/user/project/issues/issues_functionalities.md index 0a123de2fe8..7989374852a 100644 --- a/doc/user/project/issues/issues_functionalities.md +++ b/doc/user/project/issues/issues_functionalities.md @@ -6,7 +6,11 @@ Please read through the [GitLab Issue Documentation](index.md) for an overview o The image bellow illustrates how an issue looks like: +<<<<<<< HEAD  +======= + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 You can find all the information on that issue on one screen. @@ -147,7 +151,11 @@ or in the issue thread. #### 15. Award emoji +<<<<<<< HEAD - Award an emoji to that issue. +======= +- Award an emoji to that issue. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 > **Tip:** Posting "+1" as comments in threads spam all @@ -168,8 +176,17 @@ Once you wrote your comment, you can either: - Click "Start discussion": start a thread within that issue's thread to discuss specific points. - Click "Comment and close issue": post your comment and close that issue in one click. +<<<<<<< HEAD #### 18. New branch - [New branch](../repository/web_editor.md#create-a-new-branch-from-an-issue): create a new branch, followed by a new merge request which will automatically close that issue as soon as that merge request is merged. +======= +#### 18. New Merge Request + +- Create a new merge request (with a new source branch named after the issue) in one action. +The merge request will automatically close that issue as soon as merged. +- Optionally, you can just create a [new branch](../repository/web_editor.md#create-a-new-branch-from-an-issue) +named after that issue. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 diff --git a/doc/user/project/milestones/img/progress.png b/doc/user/project/milestones/img/progress.png Binary files differnew file mode 100644 index 00000000000..c85aecca729 --- /dev/null +++ b/doc/user/project/milestones/img/progress.png diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md index a43a42a8fe8..99233ed5ae2 100644 --- a/doc/user/project/milestones/index.md +++ b/doc/user/project/milestones/index.md @@ -44,3 +44,11 @@ special options available when filtering by milestone: * **Started** - show issues or merge requests from any milestone with a start date less than today. Note that this can return results from several milestones in the same project. + +## Milestone progress statistics + +Milestone statistics can be viewed in the milestone sidebar. The milestone percentage statistic +is calculated as; closed and merged merge requests plus all closed issues divided by +total merge requests and issues. + + diff --git a/doc/user/project/new_ci_build_permissions_model.md b/doc/user/project/new_ci_build_permissions_model.md index f846736028f..e9512497d6c 100644 --- a/doc/user/project/new_ci_build_permissions_model.md +++ b/doc/user/project/new_ci_build_permissions_model.md @@ -89,7 +89,7 @@ to steal the tokens of other jobs. ## Pipeline triggers -Since 9.0 [pipelnie triggers][triggers] do support the new permission model. +Since 9.0 [pipeline triggers][triggers] do support the new permission model. The new triggers do impersonate their associated user including their access to projects and their project permissions. To migrate trigger to use new permisison model use **Take ownership**. @@ -100,7 +100,7 @@ In versions before GitLab 8.12, all CI jobs 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's **Settings > 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. diff --git a/doc/user/project/pages/getting_started_part_four.md b/doc/user/project/pages/getting_started_part_four.md index 50767095aa0..bd0cb437924 100644 --- a/doc/user/project/pages/getting_started_part_four.md +++ b/doc/user/project/pages/getting_started_part_four.md @@ -1,8 +1,9 @@ # GitLab Pages from A to Z: Part 4 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: intermediate || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) diff --git a/doc/user/project/pages/getting_started_part_one.md b/doc/user/project/pages/getting_started_part_one.md index e92549aa0df..2f104c7becc 100644 --- a/doc/user/project/pages/getting_started_part_one.md +++ b/doc/user/project/pages/getting_started_part_one.md @@ -1,15 +1,16 @@ # GitLab Pages from A to Z: Part 1 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: beginner || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - **Part 1: Static sites and GitLab Pages domains** - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) - [Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](getting_started_part_three.md) - [Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages](getting_started_part_four.md) -## GitLab Pages form A to Z +## GitLab Pages from A to Z This is a comprehensive guide, made for those who want to publish a website with GitLab Pages but aren't familiar with diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md index 80f16e43e20..53fd1786cfa 100644 --- a/doc/user/project/pages/getting_started_part_three.md +++ b/doc/user/project/pages/getting_started_part_three.md @@ -1,8 +1,9 @@ # GitLab Pages from A to Z: Part 3 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: beginner || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) diff --git a/doc/user/project/pages/getting_started_part_two.md b/doc/user/project/pages/getting_started_part_two.md index 578ad13f5df..64de0463dad 100644 --- a/doc/user/project/pages/getting_started_part_two.md +++ b/doc/user/project/pages/getting_started_part_two.md @@ -1,8 +1,9 @@ # GitLab Pages from A to Z: Part 2 -> **Type**: user guide || +> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || > **Level**: beginner || -> **Author**: [Marcia Ramos](https://gitlab.com/marcia) +> **Author**: [Marcia Ramos](https://gitlab.com/marcia) || +> **Publication date:** 2017/02/22 - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - **Part 2: Quick start guide - Setting up GitLab Pages** @@ -56,7 +57,7 @@ created for the steps below.  -1. Enable Shared Runners for your fork: navigate to your **Project**'s **Settings** > **CI/CD Pipelines** +1. Enable Shared Runners for your fork: navigate to your **Project**'s **Settings** > **Pipelines** 1. Trigger a build (push a change to any file) 1. As soon as the build passes, your website will have been deployed with GitLab Pages. Your website URL will be available under your **Project**'s **Settings** > **Pages** diff --git a/doc/user/project/pipelines/job_artifacts.md b/doc/user/project/pipelines/job_artifacts.md index 151ee4728ad..1dffedc6077 100644 --- a/doc/user/project/pipelines/job_artifacts.md +++ b/doc/user/project/pipelines/job_artifacts.md @@ -12,7 +12,7 @@ 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/job_artifacts.md](../../../administration/job_artifacts.md). + [administration/job_artifacts](../../../administration/job_artifacts.md). Artifacts is a list of files and directories which are attached to a job after it completes successfully. This feature is enabled by default in all @@ -29,25 +29,40 @@ pdf: artifacts: paths: - mycv.pdf + expire_in: 1 week ``` 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. +are relative to the repository that was cloned during the build. These uploaded +artifacts will be kept in GitLab for 1 week as defined by the `expire_in` +definition. You have the option to keep the artifacts from expiring via the +[web interface](#browsing-job-artifacts). If you don't define an expiry date, +the artifacts will be kept forever. -For more examples on artifacts, follow the artifacts reference in -[`.gitlab-ci.yml` documentation](../../../ci/yaml/README.md#artifacts). +For more examples on artifacts, follow the [artifacts reference in +`.gitlab-ci.yml`](../../../ci/yaml/README.md#artifacts). ## Browsing job artifacts >**Note:** +<<<<<<< HEAD With GitLab 9.2, PDFs, images, videos and other formats can be previewed directly in the job artifacts browser without the need to download them. After a job finishes, if you visit the job'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. +======= +With GitLab 9.2, PDFs, images, videos and other formats can be previewed +directly in the job artifacts browser without the need to download them. + +After a job finishes, if you visit the job's specific page, there are three +buttons. You can download the artifacts archive or browse its contents, whereas +the **Keep** button appears only if you have set an [expiry date] to the +artifacts in case you changed your mind and want to keep them. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259  @@ -103,7 +118,7 @@ https://example.com/<namespace>/<project>/builds/artifacts/<ref>/download?job=<j To download a single file from the artifacts use the following URL: ``` -https://example.com/<namespace>/<project>/builds/artifacts/<ref>/file/<path_to_file>?job=<job_name> +https://example.com/<namespace>/<project>/builds/artifacts/<ref>/raw/<path_to_file>?job=<job_name> ``` For example, to download the latest artifacts of the job named `coverage` of @@ -118,7 +133,7 @@ To download the file `coverage/index.html` from the same artifacts use the following URL: ``` -https://gitlab.com/gitlab-org/gitlab-ce/builds/artifacts/master/file/coverage/index.html?job=coverage +https://gitlab.com/gitlab-org/gitlab-ce/builds/artifacts/master/raw/coverage/index.html?job=coverage ``` There is also a URL to browse the latest job artifacts: @@ -145,3 +160,5 @@ information in the UI.  + +[expiry date]: ../../../ci/yaml/README.md#artifacts-expire_in diff --git a/doc/user/project/pipelines/schedules.md b/doc/user/project/pipelines/schedules.md index 641876f948f..f31659da868 100644 --- a/doc/user/project/pipelines/schedules.md +++ b/doc/user/project/pipelines/schedules.md @@ -53,7 +53,11 @@ Sidekiq, which runs according to its interval. For example, if you set a schedule to create a pipeline every minute (`* * * * *`) and the Sidekiq worker runs on 00:00 and 12:00 every day (`0 */12 * * *`), only 2 pipelines will be created per day. To change the Sidekiq worker's frequency, you have to edit the +<<<<<<< HEAD `trigger_schedule_worker_cron` value in your `gitlab.rb` and restart GitLab. +======= +`pipeline_schedule_worker_cron` value in your `gitlab.rb` and restart GitLab. +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 For GitLab.com, you can check the [dedicated settings page][settings]. If you don't have admin access to the server, ask your administrator. diff --git a/doc/user/project/pipelines/settings.md b/doc/user/project/pipelines/settings.md index 88246e22391..1d2eba4f74b 100644 --- a/doc/user/project/pipelines/settings.md +++ b/doc/user/project/pipelines/settings.md @@ -1,12 +1,7 @@ -# CI/CD pipelines settings +# Pipelines settings -To reach the pipelines settings: - -1. Navigate to your project and click the cog icon in the upper right corner. - -  - -1. Select **CI/CD Pipelines** from the menu. +To reach the pipelines settings navigate to your project's +**Settings ➔ CI/CD Pipelines**. The following settings can be configured per project. diff --git a/doc/user/project/protected_branches.md b/doc/user/project/protected_branches.md index f7a686d2ccf..7650020b37e 100644 --- a/doc/user/project/protected_branches.md +++ b/doc/user/project/protected_branches.md @@ -27,11 +27,8 @@ See the [Changelog](#changelog) section for changes over time. To protect a branch, you need to have at least Master permission level. Note that the `master` branch is protected by default. -1. Navigate to the main page of the project. -1. In the upper right corner, click the settings wheel and select **Protected branches**. - -  - +1. Navigate to your project's **Settings ➔ Repository** +1. Scroll to find the **Protected branches** section. 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. diff --git a/doc/workflow/gitlab_flow.md b/doc/workflow/gitlab_flow.md index 1b172b21f3d..e10ccc4fc46 100644 --- a/doc/workflow/gitlab_flow.md +++ b/doc/workflow/gitlab_flow.md @@ -67,7 +67,7 @@ With GitLab flow we offer additional guidance for these questions.  GitHub flow does assume you are able to deploy to production every time you merge a feature branch. -This is possible for SaaS applications but there are many cases where this is not possible. +This is possible for e.g. SaaS applications, but there are many cases where this is not possible. One would be a situation where you are not in control of the exact release moment, for example an iOS application that needs to pass App Store validation. Another example is when you have deployment windows (workdays from 10am to 4pm when the operations team is at full capacity) but you also merge code at other times. In these cases you can make a production branch that reflects the deployed code. @@ -134,7 +134,7 @@ If the assigned person does not feel comfortable they can close the merge reques In GitLab it is common to protect the long-lived branches (e.g. the master branch) so that normal developers [can't modify these protected branches](http://docs.gitlab.com/ce/permissions/permissions.html). So if you want to merge it into a protected branch you assign it to someone with master authorizations. -## Issues with GitLab flow +## Issue tracking with GitLab flow  @@ -173,9 +173,9 @@ It is possible that one feature branch solves more than one issue.  -Linking to the issue can happen by mentioning them from commit messages (fixes #14, closes #67, etc.) or from the merge request description. -In GitLab this creates a comment in the issue that the merge requests mentions the issue. -And the merge request shows the linked issues. +Linking to issues can happen by mentioning them in commit messages (fixes #14, closes #67, etc.) or in the merge request description. +GitLab then creates links to the mentioned issues and creates comments in the corresponding issues linking back to the merge request. + These issues are closed once code is merged into the default branch. If you only want to make the reference without closing the issue you can also just mention it: "Duck typing is preferred. #12". @@ -300,7 +300,7 @@ If there are no merge conflicts and the feature branches are short lived the ris If there are merge conflicts you merge the master branch into the feature branch and the CI server will rerun the tests. If you have long lived feature branches that last for more than a few days you should make your issues smaller. -## Merging in other code +## Working wih feature branches  diff --git a/doc/workflow/notifications/settings.png b/doc/workflow/img/notification_global_settings.png Binary files differindex 8a5494d16a8..8a5494d16a8 100644 --- a/doc/workflow/notifications/settings.png +++ b/doc/workflow/img/notification_global_settings.png diff --git a/doc/workflow/img/notification_group_settings.png b/doc/workflow/img/notification_group_settings.png Binary files differnew file mode 100644 index 00000000000..fc096f46901 --- /dev/null +++ b/doc/workflow/img/notification_group_settings.png diff --git a/doc/workflow/img/notification_project_settings.png b/doc/workflow/img/notification_project_settings.png Binary files differnew file mode 100644 index 00000000000..006432f65c9 --- /dev/null +++ b/doc/workflow/img/notification_project_settings.png diff --git a/doc/workflow/lfs/lfs_administration.md b/doc/workflow/lfs/lfs_administration.md index 3a6773909d6..d768b73286d 100644 --- a/doc/workflow/lfs/lfs_administration.md +++ b/doc/workflow/lfs/lfs_administration.md @@ -50,7 +50,7 @@ and [projects APIs](../../api/projects.md). * 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 +* Support for removing unreferenced LFS objects was added in 8.14 onwards. * 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. * The storage statistics currently count each LFS object multiple times for diff --git a/doc/workflow/notifications.md b/doc/workflow/notifications.md index e91d36987a9..3e2e7d0f7b6 100644 --- a/doc/workflow/notifications.md +++ b/doc/workflow/notifications.md @@ -6,7 +6,7 @@ GitLab has a notification system in place to notify a user of events that are im You can find notification settings under the user profile. - + Notification settings are divided into three groups: @@ -32,19 +32,23 @@ anything that is set at Global Settings. #### Group Settings + + Group Settings are taking precedence over Global Settings but are on a level below Project Settings. This means that you can set a different level of notifications per group while still being able to have a finer level setting per project. Organization like this is suitable for users that belong to different groups but don't have the same need for being notified for every group they are member of. -These settings can be configured on group page or user profile notifications dropdown. +These settings can be configured on group page under the name of the group. It will be the dropdown with the bell icon. They can also be configured on the user profile notifications dropdown. #### Project Settings + + Project Settings are at the top level and any setting placed at this level will take precedence of any other setting. This is suitable for users that have different needs for notifications per project basis. -These settings can be configured on project page or user profile notifications dropdown. +These settings can be configured on project page under the name of the project. It will be the dropdown with the bell icon. They can also be configured on the user profile notifications dropdown. ## Notification events diff --git a/features/dashboard/starred_projects.feature b/features/dashboard/starred_projects.feature deleted file mode 100644 index 9dfd2fbab9c..00000000000 --- a/features/dashboard/starred_projects.feature +++ /dev/null @@ -1,12 +0,0 @@ -@dashboard -Feature: Dashboard Starred Projects - Background: - Given I sign in as a user - And public project "Community" - And I starred project "Community" - And I own project "Shop" - And I visit dashboard starred projects page - - Scenario: I should see projects list - Then I should see project "Community" - And I should not see project "Shop" diff --git a/features/profile/active_tab.feature b/features/profile/active_tab.feature index 788b7895d72..21d7d6c3800 100644 --- a/features/profile/active_tab.feature +++ b/features/profile/active_tab.feature @@ -23,7 +23,7 @@ Feature: Profile Active Tab Then the active main tab should be Preferences And no other main tabs should be active - Scenario: On Profile Audit Log - Given I visit Audit Log page - Then the active main tab should be Audit Log + Scenario: On Profile Authentication log + Given I visit Authentication log page + Then the active main tab should be Authentication log And no other main tabs should be active diff --git a/features/profile/profile.feature b/features/profile/profile.feature index 70f47c97173..3263d3e212b 100644 --- a/features/profile/profile.feature +++ b/features/profile/profile.feature @@ -63,7 +63,7 @@ Feature: Profile Given I logout And I sign in via the UI And I have activity - When I visit Audit Log page + When I visit Authentication log page Then I should see my activity Scenario: I visit my user page diff --git a/features/project/hooks.feature b/features/project/hooks.feature deleted file mode 100644 index 627738004c4..00000000000 --- a/features/project/hooks.feature +++ /dev/null @@ -1,37 +0,0 @@ -Feature: Project Hooks - Background: - Given I sign in as a user - And I own project "Shop" - - Scenario: I should see hook list - Given project has hook - When I visit project hooks page - Then I should see project hook - - Scenario: I add new hook - Given I visit project hooks page - When I submit new hook - Then I should see newly created hook - - Scenario: I add new hook with SSL verification enabled - Given I visit project hooks page - When I submit new hook with SSL verification enabled - Then I should see newly created hook with SSL verification enabled - - Scenario: I test hook - Given project has hook - And I visit project hooks page - When I click test hook button - Then hook should be triggered - - Scenario: I test a hook on empty project - Given I own empty project with hook - And I visit project hooks page - When I click test hook button - Then I should see hook error message - - Scenario: I test a hook on down URL - Given project has hook - And I visit project hooks page - When I click test hook button with invalid URL - Then I should see hook service down error message diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index 1b00d8a32a0..4f905674d8c 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -12,11 +12,13 @@ Feature: Project Issues Given I should see "Release 0.4" in issues And I should not see "Release 0.3" in issues + @javascript Scenario: I should see closed issues Given I click link "Closed" Then I should see "Release 0.3" in issues And I should not see "Release 0.4" in issues + @javascript Scenario: I should see all issues Given I click link "All" Then I should see "Release 0.3" in issues diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index a8c528d3d6f..0ebeded7fc5 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -38,11 +38,13 @@ Feature: Project Merge Requests When I visit merge request page "Bug NS-08" Then I should see the diverged commits count + @javascript Scenario: I should see rejected merge requests Given I click link "Closed" Then I should see "Feature NS-03" in merge requests And I should not see "Bug NS-04" in merge requests + @javascript Scenario: I should see all merge requests Given I click link "All" Then I should see "Feature NS-03" in merge requests diff --git a/features/project/merge_requests/accept.feature b/features/project/merge_requests/accept.feature index c45ed9ea68b..184969242a5 100644 --- a/features/project/merge_requests/accept.feature +++ b/features/project/merge_requests/accept.feature @@ -7,6 +7,10 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request and removing the source branch Given I am on the Merge Request detail page +<<<<<<< HEAD +======= + When I check the "Remove source branch" option +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 And I click on Accept Merge Request Then I should see merge request merged And I should not see the Remove Source Branch button @@ -14,6 +18,10 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request when URL has an anchor Given I am on the Merge Request detail with note anchor page +<<<<<<< HEAD +======= + When I check the "Remove source branch" option +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 And I click on Accept Merge Request Then I should see merge request merged And I should not see the Remove Source Branch button diff --git a/features/project/project.feature b/features/project/project.feature index aa22401c88e..23817ef3ac9 100644 --- a/features/project/project.feature +++ b/features/project/project.feature @@ -18,6 +18,7 @@ Feature: Project Then I should see the default project avatar And I should not see the "Remove avatar" button + @javascript Scenario: I should have readme on page And I visit project "Shop" page Then I should see project "Shop" README diff --git a/features/project/service.feature b/features/project/service.feature index cce5f58adec..54f07ebca92 100644 --- a/features/project/service.feature +++ b/features/project/service.feature @@ -11,77 +11,77 @@ Feature: Project Services When I visit project "Shop" services page And I click hipchat service link And I fill hipchat settings - Then I should see hipchat service settings saved + Then I should see the Hipchat success message Scenario: Activate hipchat service with custom server When I visit project "Shop" services page And I click hipchat service link And I fill hipchat settings with custom server - Then I should see hipchat service settings with custom server saved + Then I should see the Hipchat success message Scenario: Activate pivotaltracker service When I visit project "Shop" services page And I click pivotaltracker service link And I fill pivotaltracker settings - Then I should see pivotaltracker service settings saved + Then I should see the Pivotaltracker success message Scenario: Activate Flowdock service When I visit project "Shop" services page And I click Flowdock service link And I fill Flowdock settings - Then I should see Flowdock service settings saved + Then I should see the Flowdock success message Scenario: Activate Assembla service When I visit project "Shop" services page And I click Assembla service link And I fill Assembla settings - Then I should see Assembla service settings saved + Then I should see the Assembla success message Scenario: Activate Slack notifications service When I visit project "Shop" services page And I click Slack notifications service link And I fill Slack notifications settings - Then I should see Slack Notifications service settings saved + Then I should see the Slack notifications success message Scenario: Activate Pushover service When I visit project "Shop" services page And I click Pushover service link And I fill Pushover settings - Then I should see Pushover service settings saved + Then I should see the Pushover success message Scenario: Activate email on push service When I visit project "Shop" services page And I click email on push service link And I fill email on push settings - Then I should see email on push service settings saved + Then I should see the Emails on push success message Scenario: Activate JIRA service When I visit project "Shop" services page And I click jira service link And I fill jira settings - Then I should see jira service settings saved + Then I should see the JIRA success message Scenario: Activate Irker (IRC Gateway) service When I visit project "Shop" services page And I click Irker service link And I fill Irker settings - Then I should see Irker service settings saved + Then I should see the Irker success message Scenario: Activate Atlassian Bamboo CI service When I visit project "Shop" services page And I click Atlassian Bamboo CI service link And I fill Atlassian Bamboo CI settings - Then I should see Atlassian Bamboo CI service settings saved + Then I should see the Bamboo success message And I should see empty field Change Password Scenario: Activate jetBrains TeamCity CI service When I visit project "Shop" services page And I click jetBrains TeamCity CI service link And I fill jetBrains TeamCity CI settings - Then I should see jetBrains TeamCity CI service settings saved + Then I should see the JetBrains success message Scenario: Activate Asana service When I visit project "Shop" services page And I click Asana service link And I fill Asana settings - Then I should see Asana service settings saved + Then I should see the Asana success message diff --git a/features/project/source/markdown_render.feature b/features/project/source/markdown_render.feature index fd583618dcf..fe4466ad241 100644 --- a/features/project/source/markdown_render.feature +++ b/features/project/source/markdown_render.feature @@ -19,12 +19,14 @@ Feature: Project Source Markdown Render And I click on Gitlab API in README Then I should see correct document rendered + @javascript Scenario: I view README in markdown branch Then I should see files from repository in markdown And I should see rendered README which contains correct links And I click on Rake tasks in README Then I should see correct directory rendered + @javascript Scenario: I view README in markdown branch to see reference links to directory Then I should see files from repository in markdown And I should see rendered README which contains correct links @@ -74,6 +76,7 @@ Feature: Project Source Markdown Render And I click on Gitlab API in README Then I should see correct document rendered for markdown branch + @javascript Scenario: I browse directory from markdown branch When I visit markdown branch Then I should see files from repository in markdown branch diff --git a/features/search.feature b/features/search.feature index 818ef436db6..f894b6b84a1 100644 --- a/features/search.feature +++ b/features/search.feature @@ -9,6 +9,7 @@ Feature: Search Given I search for "Sho" Then I should see "Shop" project link + @javascript Scenario: I should see issues I am looking for And project has issues When I search for "Foo" @@ -16,6 +17,7 @@ Feature: Search Then I should see "Foo" link in the search results And I should not see "Bar" link in the search results + @javascript Scenario: I should see merge requests I am looking for And project has merge requests When I search for "Foo" @@ -23,6 +25,7 @@ Feature: Search Then I should see "Foo" link in the search results And I should not see "Bar" link in the search results + @javascript Scenario: I should see milestones I am looking for And project has milestones When I search for "Foo" @@ -78,6 +81,7 @@ Feature: Search And I search for "Sho" Then I should see "Shop" project link + @javascript Scenario: I logout and should see issues I am looking for Given project "Shop" is public And I logout directly diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb index bf09d7b7114..71c69a4fdea 100644 --- a/features/steps/dashboard/dashboard.rb +++ b/features/steps/dashboard/dashboard.rb @@ -22,7 +22,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps end step 'I click "Create merge request" link' do - click_link "Create merge request" + find_link("Create merge request", visible: false).trigger('click') end step 'I see prefilled new Merge Request page' do diff --git a/features/steps/dashboard/event_filters.rb b/features/steps/dashboard/event_filters.rb index ca3cd0ecc4e..a745254cc31 100644 --- a/features/steps/dashboard/event_filters.rb +++ b/features/steps/dashboard/event_filters.rb @@ -1,5 +1,5 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps - include WaitForAjax + include WaitForRequests include SharedAuthentication include SharedPaths include SharedProject @@ -73,20 +73,20 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps end When 'I click "push" event filter' do - wait_for_ajax + wait_for_requests click_link("Push events") - wait_for_ajax + wait_for_requests end When 'I click "team" event filter' do - wait_for_ajax + wait_for_requests click_link("Team") - wait_for_ajax + wait_for_requests end When 'I click "merge" event filter' do - wait_for_ajax + wait_for_requests click_link("Merge events") - wait_for_ajax + wait_for_requests end end diff --git a/features/steps/dashboard/new_project.rb b/features/steps/dashboard/new_project.rb index 4fb16d3bb57..530fd6f7bdb 100644 --- a/features/steps/dashboard/new_project.rb +++ b/features/steps/dashboard/new_project.rb @@ -4,7 +4,13 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps include SharedProject step 'I click "New project" link' do - page.within('.content') do + page.within '#content-body' do + click_link "New project" + end + end + + step 'I click "New project" in top right menu' do + page.within '.header-content' do click_link "New project" end end diff --git a/features/steps/dashboard/todos.rb b/features/steps/dashboard/todos.rb index b56558ba0d2..4a33babe3bd 100644 --- a/features/steps/dashboard/todos.rb +++ b/features/steps/dashboard/todos.rb @@ -3,7 +3,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps include SharedPaths include SharedProject include SharedUser - include WaitForAjax + include WaitForRequests step '"John Doe" is a developer of project "Shop"' do project.team << [john_doe, :developer] @@ -55,7 +55,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps merge_request_reference = merge_request.to_reference(full: true) issue_reference = issue.to_reference(full: true) - click_link 'Mark all as done' + find('.js-todos-mark-all').trigger('click') page.within('.todos-count') { expect(page).to have_content '0' } expect(page).to have_content 'To do 0' @@ -69,7 +69,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps end step 'I should see the todo marked as done' do - click_link 'Done 1' + find('.todos-done a').trigger('click') expect(page).to have_link project.name_with_namespace should_see_todo(1, "John Doe assigned you merge request #{merge_request.to_reference(full: true)}", merge_request.title, state: :done_irreversible) @@ -79,7 +79,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps merge_request_reference = merge_request.to_reference(full: true) issue_reference = issue.to_reference(full: true) - click_link 'Done 4' + find('.todos-done a').trigger('click') expect(page).to have_link project.name_with_namespace should_see_todo(1, "John Doe assigned you merge request #{merge_request_reference}", merge_request.title, state: :done_irreversible) @@ -138,9 +138,9 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps end step 'I should be directed to the corresponding page' do - page.should have_css('.identifier', text: 'Merge Request !1') + page.should have_css('.identifier', text: 'Merge request !1') # Merge request page loads and issues a number of Ajax requests - wait_for_ajax + wait_for_requests end def should_see_todo(position, title, body, state: :pending) diff --git a/features/steps/explore/projects.rb b/features/steps/explore/projects.rb index 7dc33ab5683..1a55f40abb9 100644 --- a/features/steps/explore/projects.rb +++ b/features/steps/explore/projects.rb @@ -49,7 +49,7 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps step 'I should see an http link to the repository' do project = Project.find_by(name: 'Community') - expect(page).to have_field('project_clone', with: project.http_url_to_repo(@user)) + expect(page).to have_field('project_clone', with: project.http_url_to_repo) end step 'I should see an ssh link to the repository' do @@ -101,7 +101,7 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps create(:merge_request, title: "Bug fix for public project", source_project: public_project, - target_project: public_project, + target_project: public_project ) end diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb index b04a7015d4e..0ab1012660c 100644 --- a/features/steps/group/members.rb +++ b/features/steps/group/members.rb @@ -1,5 +1,5 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps - include WaitForAjax + include WaitForRequests include SharedAuthentication include SharedPaths include SharedGroup @@ -58,7 +58,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps click_link 'Developer' end - wait_for_ajax + wait_for_requests end end diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb index 0b0983f0d06..0542b06c0ab 100644 --- a/features/steps/group/milestones.rb +++ b/features/steps/group/milestones.rb @@ -1,5 +1,5 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps - include WaitForAjax + include WaitForRequests include SharedAuthentication include SharedPaths include SharedGroup @@ -91,7 +91,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps end step 'I should see the list of labels' do - wait_for_ajax + wait_for_requests page.within('#tab-labels') do expect(page).to have_content 'bug' diff --git a/features/steps/profile/active_tab.rb b/features/steps/profile/active_tab.rb index 4724a326277..069d4e6a23d 100644 --- a/features/steps/profile/active_tab.rb +++ b/features/steps/profile/active_tab.rb @@ -19,7 +19,7 @@ class Spinach::Features::ProfileActiveTab < Spinach::FeatureSteps ensure_active_main_tab('Preferences') end - step 'the active main tab should be Audit Log' do - ensure_active_main_tab('Audit Log') + step 'the active main tab should be Authentication log' do + ensure_active_main_tab('Authentication log') end end diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb index eec375b0532..4b72355b125 100644 --- a/features/steps/project/builds/artifacts.rb +++ b/features/steps/project/builds/artifacts.rb @@ -3,7 +3,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps include SharedProject include SharedBuilds include RepoHelpers - include WaitForAjax + include WaitForRequests step 'I click artifacts download button' do click_link 'Download' @@ -25,7 +25,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps step 'I should see the build header' do page.within('.build-header') do - expect(page).to have_content "Job ##{@build.id} in pipeline ##{@pipeline.id} for commit #{@pipeline.short_sha}" + expect(page).to have_content "Job ##{@build.id} in pipeline ##{@pipeline.id} for #{@pipeline.short_sha}" end end @@ -79,7 +79,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps step 'I click a link to file within build artifacts' do page.within('.tree-table') { find_link('ci_artifacts.txt').click } - wait_for_ajax + wait_for_requests end step 'I see a download link' do diff --git a/features/steps/project/create.rb b/features/steps/project/create.rb index 5f5f806df36..28be9c6df5b 100644 --- a/features/steps/project/create.rb +++ b/features/steps/project/create.rb @@ -5,7 +5,9 @@ class Spinach::Features::ProjectCreate < Spinach::FeatureSteps step 'fill project form with valid data' do fill_in 'project_path', with: 'Empty' - click_button "Create project" + page.within '#content-body' do + click_button "Create project" + end end step 'I should see project page' do diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb index 7591e7d5612..35df403a85f 100644 --- a/features/steps/project/fork.rb +++ b/features/steps/project/fork.rb @@ -5,7 +5,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps step 'I click link "Fork"' do expect(page).to have_content "Shop" - click_link "Fork project" + click_link "Fork" end step 'I am a member of project "Shop"' do @@ -42,7 +42,9 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps end step 'I click link "New merge request"' do - page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request') + page.within '#content-body' do + page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request') + end end step 'I should see the new merge request page for my namespace' do diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb index 310db6e6dad..334a8dad3b6 100644 --- a/features/steps/project/forked_merge_requests.rb +++ b/features/steps/project/forked_merge_requests.rb @@ -4,7 +4,11 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps include SharedNote include SharedPaths include Select2Helper +<<<<<<< HEAD include WaitForVueResource +======= + include WaitForRequests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 step 'I am a member of project "Shop"' do @project = ::Project.find_by(name: "Shop") @@ -17,7 +21,9 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps end step 'I click link "New Merge Request"' do - page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request') + page.within '#content-body' do + page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request') + end end step 'I should see merge request "Merge Request On Forked Project"' do @@ -33,7 +39,11 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps expect(page).to have_content @merge_request.source_branch expect(page).to have_content @merge_request.target_branch +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I fill out a "Merge Request On Forked Project" merge request' do @@ -47,6 +57,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps first('.dropdown-target-project a', text: @project.path_with_namespace) first('.js-source-branch').click + wait_for_requests first('.dropdown-source-branch .dropdown-content a', text: 'fix').click click_button "Compare branches and continue" @@ -62,31 +73,6 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps click_button "Submit merge request" end - step 'I follow the target commit link' do - commit = @project.repository.commit - click_link commit.short_id(8) - end - - step 'I should see the commit under the forked from project' do - commit = @project.repository.commit - expect(page).to have_content(commit.message) - end - - step 'I click "Create Merge Request on fork" link' do - click_link "Create Merge Request on fork" - end - - step 'I see prefilled new Merge Request page for the forked project' do - expect(current_path).to eq new_namespace_project_merge_request_path(@forked_project.namespace, @forked_project) - expect(find("#merge_request_source_project_id").value).to eq @forked_project.id.to_s - expect(find("#merge_request_target_project_id").value).to eq @project.id.to_s - expect(find("#merge_request_source_branch").value).to have_content "new_design" - expect(find("#merge_request_target_branch").value).to have_content "master" - expect(find("#merge_request_title").value).to eq "New Design" - verify_commit_link(".mr_target_commit", @project) - verify_commit_link(".mr_source_commit", @forked_project) - end - step 'I update the merge request title' do fill_in "merge_request_title", with: "An Edited Forked Merge Request" end @@ -155,10 +141,4 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps expect(page).to have_content @project.users.first.name end end - - # Verify a link is generated against the correct project - def verify_commit_link(container_div, container_project) - # This should force a wait for the javascript to execute - expect(find(:div, container_div).find(".commit_short_id")['href']).to have_content "#{container_project.path_with_namespace}/commit" - end end diff --git a/features/steps/project/hooks.rb b/features/steps/project/hooks.rb deleted file mode 100644 index 945d58a6458..00000000000 --- a/features/steps/project/hooks.rb +++ /dev/null @@ -1,75 +0,0 @@ -require 'webmock' - -class Spinach::Features::ProjectHooks < Spinach::FeatureSteps - include SharedAuthentication - include SharedProject - include SharedPaths - include RSpec::Matchers - include RSpec::Mocks::ExampleMethods - include WebMock::API - - step 'project has hook' do - @hook = create(:project_hook, project: current_project) - end - - step 'I own empty project with hook' do - @project = create(:empty_project, - name: 'Empty Project', namespace: @user.namespace) - @hook = create(:project_hook, project: current_project) - end - - step 'I should see project hook' do - expect(page).to have_content @hook.url - end - - step 'I submit new hook' do - @url = 'http://example.org/1' - fill_in "hook_url", with: @url - expect { click_button "Add webhook" }.to change(ProjectHook, :count).by(1) - end - - step 'I submit new hook with SSL verification enabled' do - @url = 'http://example.org/2' - fill_in "hook_url", with: @url - check "hook_enable_ssl_verification" - expect { click_button "Add webhook" }.to change(ProjectHook, :count).by(1) - end - - step 'I should see newly created hook' do - expect(current_path).to eq namespace_project_settings_integrations_path(current_project.namespace, current_project) - expect(page).to have_content(@url) - end - - step 'I should see newly created hook with SSL verification enabled' do - expect(current_path).to eq namespace_project_settings_integrations_path(current_project.namespace, current_project) - expect(page).to have_content(@url) - expect(page).to have_content("SSL Verification: enabled") - end - - step 'I click test hook button' do - stub_request(:post, @hook.url).to_return(status: 200) - click_link 'Test' - end - - step 'I click test hook button with invalid URL' do - stub_request(:post, @hook.url).to_raise(SocketError) - click_link 'Test' - end - - step 'hook should be triggered' do - expect(current_path).to eq namespace_project_settings_integrations_path(current_project.namespace, current_project) - expect(page).to have_selector '.flash-notice', - text: 'Hook executed successfully: HTTP 200' - end - - step 'I should see hook error message' do - expect(page).to have_selector '.flash-alert', - text: 'Hook execution failed. '\ - 'Ensure the project has commits.' - end - - step 'I should see hook service down error message' do - expect(page).to have_selector '.flash-alert', - text: 'Hook execution failed: Exception from' - end -end diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index 637e6568267..e4a559d8ff5 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -28,7 +28,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I click link "Closed"' do - find('.issues-state-filters a', text: "Closed").click + find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click end step 'I click button "Unsubscribe"' do @@ -44,7 +44,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I click link "All"' do - click_link "All" + find('.issues-state-filters [data-state="all"] span', text: 'All').click # Waits for load expect(find('.issues-state-filters > .active')).to have_content 'All' end @@ -62,7 +62,9 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I click link "New issue"' do - page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue') + page.within '#content-body' do + page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue') + end end step 'I click "author" dropdown' do diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 573be44c695..f4c567c787c 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -7,15 +7,21 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps include SharedMarkdown include SharedDiffNote include SharedUser +<<<<<<< HEAD include WaitForAjax include WaitForVueResource +======= + include WaitForRequests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 after do - wait_for_ajax if javascript_test? + wait_for_requests if javascript_test? end step 'I click link "New Merge Request"' do - page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request') + page.within '#content-body' do + page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request') + end end step 'I click link "Bug NS-04"' do @@ -27,42 +33,56 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I click link "All"' do - click_link "All" + find('.issues-state-filters [data-state="all"] span', text: 'All').click # Waits for load expect(find('.issues-state-filters > .active')).to have_content 'All' end step 'I click link "Merged"' do - click_link "Merged" + find('#state-merged').trigger('click') end step 'I click link "Closed"' do - page.within('.issues-state-filters') do - click_link "Closed" - end + find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click end step 'I should see merge request "Wiki Feature"' do page.within '.merge-request' do expect(page).to have_content "Wiki Feature" end +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I should see closed merge request "Bug NS-04"' do expect(page).to have_content "Bug NS-04" expect(page).to have_content "Closed by" +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I should see merge request "Bug NS-04"' do expect(page).to have_content "Bug NS-04" +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I should see merge request "Feature NS-05"' do expect(page).to have_content "Feature NS-05" +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I should not see "master" branch' do @@ -99,7 +119,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I click button "Unsubscribe"' do click_on "Unsubscribe" - wait_for_ajax + wait_for_requests end step 'I click link "Close"' do @@ -300,6 +320,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I change the comment "Line is wrong" to "Typo, please fix" on diff' do page.within('.diff-file:nth-of-type(5) .note') do + find('.more-actions').click + find('.more-actions .dropdown-menu li', match: :first) + find('.js-note-edit').click page.within('.current-note-edit-form', visible: true) do @@ -325,13 +348,16 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I delete the comment "Line is wrong" on diff' do page.within('.diff-file:nth-of-type(5) .note') do + find('.more-actions').click + find('.more-actions .dropdown-menu li', match: :first) + find('.js-note-delete').click end end step 'I click on the Discussion tab' do page.within '.merge-request-tabs' do - click_link 'Discussion' + find('.notes-tab').trigger('click') end # Waits for load @@ -353,7 +379,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see a discussion by user "John Doe" has started on diff' do # Trigger a refresh of notes execute_script("$(document).trigger('visibilitychange');") - wait_for_ajax + wait_for_requests page.within(".notes .discussion") do page.should have_content "#{user_exists("John Doe").name} #{user_exists("John Doe").to_reference} started a discussion" page.should have_content sample_commit.line_code_path @@ -363,12 +389,20 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see a badge of "1" next to the discussion link' do expect_discussion_badge_to_have_counter("1") +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I should see a badge of "0" next to the discussion link' do expect_discussion_badge_to_have_counter("0") +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I should see a discussion has started on commit diff' do @@ -376,7 +410,11 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.should have_content "#{current_user.name} #{current_user.to_reference} started a discussion on commit" page.should have_content sample_commit.line_code_path page.should have_content "Line is wrong" +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end @@ -384,7 +422,11 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.within(".notes .discussion") do page.should have_content "#{current_user.name} #{current_user.to_reference} started a discussion on commit" page.should have_content "One comment to rule them all" +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end @@ -410,7 +452,11 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see merged request' do page.within '.status-box' do expect(page).to have_content "Merged" +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end @@ -422,7 +468,11 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.within '.status-box' do expect(page).to have_content "Open" end +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I click link "Hide inline discussion" of the third file' do @@ -446,7 +496,11 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see a comment like "Line is wrong" in the third file' do page.within '.files>div:nth-child(3) .note-body > .note-text' do expect(page).to have_visible_content "Line is wrong" +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end @@ -470,7 +524,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps click_button "Comment" end - wait_for_ajax + wait_for_requests page.within ".files>div:nth-child(2) .note-body > .note-text" do expect(page).to have_content "Line is correct" @@ -485,7 +539,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps click_button "Comment" end - wait_for_ajax + wait_for_requests end step 'I should still see a comment like "Line is correct" in the second file' do @@ -514,7 +568,11 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see comments on the side-by-side diff page' do page.within '.files>div:nth-child(2) .parallel .note-body > .note-text' do expect(page).to have_visible_content "Line is correct" +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end @@ -538,7 +596,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I should see new target branch changes' do expect(page).to have_content 'Request to merge fix into feature' expect(page).to have_content 'changed target branch from merge-test to feature' - wait_for_ajax + wait_for_requests end step 'I click on "Email Patches"' do @@ -556,7 +614,14 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step '"Bug NS-05" has CI status' do project = merge_request.source_project project.enable_ci - pipeline = create :ci_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch + + pipeline = + create(:ci_pipeline, + project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + head_pipeline_of: merge_request) + create :ci_build, pipeline: pipeline end @@ -571,7 +636,11 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps expect(page).to have_content /([0-9]+ commits behind)/ end +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I should not see the diverged commits count' do @@ -579,7 +648,11 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps expect(page).not_to have_content /([0-9]+ commit[s]? behind)/ end +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end def merge_request @@ -596,7 +669,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps click_button "Comment" end - wait_for_ajax + wait_for_requests page.within(".notes_holder", visible: true) do expect(page).to have_content message diff --git a/features/steps/project/merge_requests/acceptance.rb b/features/steps/project/merge_requests/acceptance.rb index 3c976f675a2..90ae5d720b1 100644 --- a/features/steps/project/merge_requests/acceptance.rb +++ b/features/steps/project/merge_requests/acceptance.rb @@ -1,7 +1,11 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps include LoginHelpers include GitlabRoutingHelper +<<<<<<< HEAD include WaitForVueResource +======= + include WaitForRequests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 step 'I am on the Merge Request detail page' do visit merge_request_path(@merge_request) @@ -11,8 +15,17 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps visit merge_request_path(@merge_request, anchor: 'note_123') end +<<<<<<< HEAD step 'I click on "Remove source branch" option' do uncheck('Remove source branch') +======= + step 'I uncheck the "Remove source branch" option' do + uncheck('Remove source branch') + end + + step 'I check the "Remove source branch" option' do + check('Remove source branch') +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I click on Accept Merge Request' do @@ -24,7 +37,11 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps # Wait for View Resource requests to complete so they don't blow up if they are # only handled after `DatabaseCleaner` has already run +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I should not see the Remove Source Branch button' do @@ -32,7 +49,11 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps # Wait for View Resource requests to complete so they don't blow up if they are # only handled after `DatabaseCleaner` has already run +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'There is an open Merge Request' do diff --git a/features/steps/project/merge_requests/revert.rb b/features/steps/project/merge_requests/revert.rb index aa76d6f8c48..9b2a4fac898 100644 --- a/features/steps/project/merge_requests/revert.rb +++ b/features/steps/project/merge_requests/revert.rb @@ -1,7 +1,11 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps include LoginHelpers include GitlabRoutingHelper +<<<<<<< HEAD include WaitForVueResource +======= + include WaitForRequests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 step 'I click on the revert button' do find("a[href='#modal-revert-commit']").click @@ -16,7 +20,11 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps step 'I should see the revert merge request notice' do page.should have_content('The merge request has been successfully reverted.') +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I should not see the revert button' do diff --git a/features/steps/project/pages.rb b/features/steps/project/pages.rb index fea82d9fb57..4e6830f738b 100644 --- a/features/steps/project/pages.rb +++ b/features/steps/project/pages.rb @@ -35,7 +35,7 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps end step 'pages are deployed' do - pipeline = @project.ensure_pipeline('HEAD', @project.commit('HEAD').sha) + pipeline = @project.pipelines.create(ref: 'HEAD', sha: @project.commit('HEAD').sha) build = build(:ci_build, project: @project, pipeline: pipeline, diff --git a/features/steps/project/project.rb b/features/steps/project/project.rb index 03d6704e1ab..7d34331db46 100644 --- a/features/steps/project/project.rb +++ b/features/steps/project/project.rb @@ -2,6 +2,7 @@ class Spinach::Features::Project < Spinach::FeatureSteps include SharedAuthentication include SharedProject include SharedPaths + include WaitForRequests step 'change project settings' do fill_in 'project_name_edit', with: 'NewName' @@ -86,6 +87,7 @@ class Spinach::Features::Project < Spinach::FeatureSteps end step 'I should see project "Shop" README' do + wait_for_requests page.within('.readme-holder') do expect(page).to have_content 'testme' end diff --git a/features/steps/project/project_milestone.rb b/features/steps/project/project_milestone.rb index dc1190b7eea..a7d3352b8c4 100644 --- a/features/steps/project/project_milestone.rb +++ b/features/steps/project/project_milestone.rb @@ -2,7 +2,7 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps include SharedAuthentication include SharedProject include SharedPaths - include WaitForAjax + include WaitForRequests step 'milestone has issue "Bugfix1" with labels: "bug", "feature"' do project = Project.find_by(name: "Shop") @@ -35,7 +35,7 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps end step 'I should see the labels "bug", "enhancement" and "feature"' do - wait_for_ajax + wait_for_requests page.within('#tab-issues') do expect(page).to have_content 'bug' diff --git a/features/steps/project/services.rb b/features/steps/project/services.rb index 772b07d0ad8..6bac4df16f8 100644 --- a/features/steps/project/services.rb +++ b/features/steps/project/services.rb @@ -34,8 +34,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see hipchat service settings saved' do - expect(find_field('Room').value).to eq 'gitlab' + step 'I should see the Hipchat success message' do + expect(page).to have_content 'HipChat activated.' end step 'I fill hipchat settings with custom server' do @@ -46,10 +46,6 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see hipchat service settings with custom server saved' do - expect(find_field('Server').value).to eq 'https://chat.example.com' - end - step 'I click pivotaltracker service link' do click_link 'PivotalTracker' end @@ -60,8 +56,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see pivotaltracker service settings saved' do - expect(find_field('Token').value).to eq 'verySecret' + step 'I should see the Pivotaltracker success message' do + expect(page).to have_content 'PivotalTracker activated.' end step 'I click Flowdock service link' do @@ -74,8 +70,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Flowdock service settings saved' do - expect(find_field('Token').value).to eq 'verySecret' + step 'I should see the Flowdock success message' do + expect(page).to have_content 'Flowdock activated.' end step 'I click Assembla service link' do @@ -88,8 +84,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Assembla service settings saved' do - expect(find_field('Token').value).to eq 'verySecret' + step 'I should see the Assembla success message' do + expect(page).to have_content 'Assembla activated.' end step 'I click Asana service link' do @@ -103,9 +99,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Asana service settings saved' do - expect(find_field('Api key').value).to eq 'verySecret' - expect(find_field('Restrict to branch').value).to eq 'master' + step 'I should see the Asana success message' do + expect(page).to have_content 'Asana activated.' end step 'I click email on push service link' do @@ -113,12 +108,13 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps end step 'I fill email on push settings' do + check 'Active' fill_in 'Recipients', with: 'qa@company.name' click_button 'Save' end - step 'I should see email on push service settings saved' do - expect(find_field('Recipients').value).to eq 'qa@company.name' + step 'I should see the Emails on push success message' do + expect(page).to have_content 'Emails on push activated.' end step 'I click Irker service link' do @@ -132,9 +128,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Irker service settings saved' do - expect(find_field('Recipients').value).to eq 'irc://chat.freenode.net/#commits' - expect(find_field('Colorize messages').value).to eq '1' + step 'I should see the Irker success message' do + expect(page).to have_content 'Irker (IRC gateway) activated.' end step 'I click Slack notifications service link' do @@ -147,8 +142,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Slack Notifications service settings saved' do - expect(find_field('Webhook').value).to eq 'https://hooks.slack.com/services/SVRWFV0VVAR97N/B02R25XN3/ZBqu7xMupaEEICInN685' + step 'I should see the Slack notifications success message' do + expect(page).to have_content 'Slack notifications activated.' end step 'I click Pushover service link' do @@ -165,12 +160,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Pushover service settings saved' do - expect(find_field('Api key').value).to eq 'verySecret' - expect(find_field('User key').value).to eq 'verySecret' - expect(find_field('Device').value).to eq 'myDevice' - expect(find_field('Priority').find('option[selected]').value).to eq '1' - expect(find_field('Sound').find('option[selected]').value).to eq 'bike' + step 'I should see the Pushover success message' do + expect(page).to have_content 'Pushover activated.' end step 'I click jira service link' do @@ -178,17 +169,18 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps end step 'I fill jira settings' do - fill_in 'URL', with: 'http://jira.example' + check 'Active' + + fill_in 'Web URL', with: 'http://jira.example' + fill_in 'JIRA API URL', with: 'http://jira.example/api' fill_in 'Username', with: 'gitlab' fill_in 'Password', with: 'gitlab' fill_in 'Project Key', with: 'GITLAB' click_button 'Save' end - step 'I should see jira service settings saved' do - expect(find_field('URL').value).to eq 'http://jira.example' - expect(find_field('Username').value).to eq 'gitlab' - expect(find_field('Project Key').value).to eq 'GITLAB' + step 'I should see the JIRA success message' do + expect(page).to have_content 'JIRA activated.' end step 'I click Atlassian Bamboo CI service link' do @@ -204,14 +196,14 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Atlassian Bamboo CI service settings saved' do - expect(find_field('Bamboo url').value).to eq 'http://bamboo.example.com' - expect(find_field('Build key').value).to eq 'KEY' - expect(find_field('Username').value).to eq 'user' + step 'I should see the Bamboo success message' do + expect(page).to have_content 'Atlassian Bamboo CI activated.' end step 'I should see empty field Change Password' do - expect(find_field('Change Password').value).to be_nil + click_link 'Atlassian Bamboo CI' + + expect(find_field('Enter new password').value).to be_nil end step 'I click JetBrains TeamCity CI service link' do @@ -227,9 +219,7 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see JetBrains TeamCity CI service settings saved' do - expect(find_field('Teamcity url').value).to eq 'http://teamcity.example.com' - expect(find_field('Build type').value).to eq 'GitlabTest_Build' - expect(find_field('Username').value).to eq 'user' + step 'I should see the JetBrains success message' do + expect(page).to have_content 'JetBrains TeamCity CI activated.' end end diff --git a/features/steps/project/snippets.rb b/features/steps/project/snippets.rb index 60febd20104..dd49701a3d9 100644 --- a/features/steps/project/snippets.rb +++ b/features/steps/project/snippets.rb @@ -3,7 +3,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps include SharedProject include SharedNote include SharedPaths - include WaitForAjax + include WaitForRequests step 'project "Shop" have "Snippet one" snippet' do create(:project_snippet, @@ -23,7 +23,9 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps end step 'I click link "New snippet"' do - first(:link, "New snippet").click + page.within '#content-body' do + first(:link, "New snippet").click + end end step 'I click link "Snippet one"' do @@ -59,7 +61,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps find('.ace_editor').native.send_keys 'Content of snippet three' end click_button "Create snippet" - wait_for_ajax + wait_for_requests end step 'I should see snippet "Snippet three"' do @@ -81,7 +83,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps fill_in "note_note", with: "Good snippet!" click_button "Comment" end - wait_for_ajax + wait_for_requests end step 'I should see comment "Good snippet!"' do diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index ef09bddddd8..d099d7af167 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -4,7 +4,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps include SharedProject include SharedPaths include RepoHelpers - include WaitForAjax + include WaitForRequests step "I don't have write access" do @project = create(:project, :repository, name: "Other Project", path: "other-project") @@ -37,12 +37,12 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I should see its content' do - wait_for_ajax + wait_for_requests expect(page).to have_content old_gitignore_content end step 'I should see its new content' do - wait_for_ajax + wait_for_requests expect(page).to have_content new_gitignore_content end @@ -372,6 +372,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps expect(page).to have_content 'Permalink' expect(page).not_to have_content 'Edit' expect(page).not_to have_content 'Blame' + expect(page).not_to have_content 'Annotate' expect(page).to have_content 'Delete' expect(page).to have_content 'Replace' end diff --git a/features/steps/project/source/markdown_render.rb b/features/steps/project/source/markdown_render.rb index abdbd795cd5..0fee158d590 100644 --- a/features/steps/project/source/markdown_render.rb +++ b/features/steps/project/source/markdown_render.rb @@ -5,7 +5,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps include SharedAuthentication include SharedPaths include SharedMarkdown - include WaitForAjax + include WaitForRequests step 'I own project "Delta"' do @project = ::Project.find_by(name: "Delta") @@ -35,7 +35,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see correct document rendered' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") - wait_for_ajax + wait_for_requests expect(page).to have_content "All API requests require authentication" end @@ -65,7 +65,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see correct maintenance file rendered' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/raketasks/maintenance.md") - wait_for_ajax + wait_for_requests expect(page).to have_content "bundle exec rake gitlab:env:info RAILS_ENV=production" end @@ -97,7 +97,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I see correct file rendered' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") - wait_for_ajax + wait_for_requests expect(page).to have_content "Contents" expect(page).to have_link "Users" expect(page).to have_link "Rake tasks" @@ -120,6 +120,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps When 'I visit markdown branch' do visit namespace_project_tree_path(@project.namespace, @project, "markdown") + wait_for_requests end When 'I visit markdown branch "README.md" blob' do @@ -142,7 +143,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I see correct file rendered in markdown branch' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") - wait_for_ajax + wait_for_requests expect(page).to have_content "Contents" expect(page).to have_link "Users" expect(page).to have_link "Rake tasks" @@ -150,7 +151,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see correct document rendered for markdown branch' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") - wait_for_ajax + wait_for_requests expect(page).to have_content "All API requests require authentication" end @@ -168,7 +169,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps # Expected link contents step 'The link with text "empty" should have url "tree/markdown"' do - wait_for_ajax + wait_for_requests find('a', text: /^empty$/)['href'] == current_host + namespace_project_tree_path(@project.namespace, @project, "markdown") end @@ -204,7 +205,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps end step 'The link with text "ID" should have url "blob/markdown/README.mdID"' do - wait_for_ajax + wait_for_requests find('a', text: /^#id$/)['href'] == current_host + namespace_project_blob_path(@project.namespace, @project, "markdown/README.md") + '#id' end @@ -299,12 +300,12 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see the correct markdown' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/users.md") - wait_for_ajax + wait_for_requests expect(page).to have_content "List users" end step 'Header "Application details" should have correct id and link' do - wait_for_ajax + wait_for_requests header_should_have_correct_id_and_link(2, 'Application details', 'application-details') end diff --git a/features/steps/search.rb b/features/steps/search.rb index f885baf8453..16c4a5ab2e4 100644 --- a/features/steps/search.rb +++ b/features/steps/search.rb @@ -10,12 +10,12 @@ class Spinach::Features::Search < Spinach::FeatureSteps step 'I search for "Foo"' do fill_in "dashboard_search", with: "Foo" - click_button "Search" + find('.btn-search').trigger('click') end step 'I search for "rspec"' do fill_in "dashboard_search", with: "rspec" - click_button "Search" + find('.btn-search').trigger('click') end step 'I search for "rspec" on project page' do @@ -25,7 +25,7 @@ class Spinach::Features::Search < Spinach::FeatureSteps step 'I search for "Wiki content"' do fill_in "dashboard_search", with: "content" - click_button "Search" + find('.btn-search').trigger('click') end step 'I click "Issues" link' do @@ -35,7 +35,7 @@ class Spinach::Features::Search < Spinach::FeatureSteps end step 'I click project "Shop" link' do - click_button 'Project' + find('.js-search-project-dropdown').trigger('click') page.within '.project-filter' do click_link project.name_with_namespace end diff --git a/features/steps/shared/active_tab.rb b/features/steps/shared/active_tab.rb index 8bae80a8707..af5db05e9e8 100644 --- a/features/steps/shared/active_tab.rb +++ b/features/steps/shared/active_tab.rb @@ -1,9 +1,9 @@ module SharedActiveTab include Spinach::DSL - include WaitForAjax + include WaitForRequests after do - wait_for_ajax if javascript_test? + wait_for_requests if javascript_test? end def ensure_active_main_tab(content) diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb index 5549fc25525..624f1a7858b 100644 --- a/features/steps/shared/builds.rb +++ b/features/steps/shared/builds.rb @@ -27,11 +27,11 @@ module SharedBuilds end step 'I visit recent build details page' do - visit namespace_project_build_path(@project.namespace, @project, @build) + visit namespace_project_job_path(@project.namespace, @project, @build) end step 'I visit project builds page' do - visit namespace_project_builds_path(@project.namespace, @project) + visit namespace_project_jobs_path(@project.namespace, @project) end step 'recent build has artifacts available' do @@ -56,7 +56,7 @@ module SharedBuilds end step 'I access artifacts download page' do - visit download_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + visit download_namespace_project_job_artifacts_path(@project.namespace, @project, @build) end step 'I see details of a build' do diff --git a/features/steps/shared/diff_note.rb b/features/steps/shared/diff_note.rb index 071aa2e3eff..36fc315599e 100644 --- a/features/steps/shared/diff_note.rb +++ b/features/steps/shared/diff_note.rb @@ -1,10 +1,10 @@ module SharedDiffNote include Spinach::DSL include RepoHelpers - include WaitForAjax + include WaitForRequests after do - wait_for_ajax if javascript_test? + wait_for_requests if javascript_test? end step 'I cancel the diff comment' do diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb index 6610b97ecb2..c2bec2a6320 100644 --- a/features/steps/shared/markdown.rb +++ b/features/steps/shared/markdown.rb @@ -30,7 +30,7 @@ module SharedMarkdown end step 'I should see the Markdown write tab' do - expect(find('.gfm-form')).to have_css('.js-md-write-button', visible: true) + expect(first('.gfm-form')).to have_link('Write', visible: true) end step 'I should see the Markdown preview' do @@ -49,9 +49,9 @@ module SharedMarkdown end step 'I preview a description text like "Bug fixed :smile:"' do - page.within('.gfm-form') do + page.within(first('.gfm-form')) do fill_in 'Description', with: 'Bug fixed :smile:' - find('.js-md-preview-button').click + click_link 'Preview' end end diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb index 7d260025052..80187b83fee 100644 --- a/features/steps/shared/note.rb +++ b/features/steps/shared/note.rb @@ -1,14 +1,19 @@ module SharedNote include Spinach::DSL - include WaitForAjax + include WaitForRequests after do - wait_for_ajax if javascript_test? + wait_for_requests if javascript_test? end step 'I delete a comment' do page.within('.main-notes-list') do - find('.note').hover + note = find('.note') + note.hover + + note.find('.more-actions').click + note.find('.more-actions .dropdown-menu li', match: :first) + find(".js-note-delete").click end end @@ -25,7 +30,7 @@ module SharedNote click_button "Comment" end - wait_for_ajax + wait_for_requests end step 'I preview a comment text like "Bug fixed :smile:"' do @@ -40,7 +45,7 @@ module SharedNote click_button "Comment" end - wait_for_ajax + wait_for_requests end step 'I write a comment like ":+1: Nice"' do @@ -127,7 +132,7 @@ module SharedNote click_button "Comment" end - wait_for_ajax + wait_for_requests end step 'The comment with the header should not have an ID' do @@ -139,8 +144,13 @@ module SharedNote step 'I edit the last comment with a +1' do page.within(".main-notes-list") do - find(".note").hover - find('.js-note-edit').click + note = find('.note') + note.hover + + note.find('.more-actions').click + note.find('.more-actions .dropdown-menu li', match: :first) + + note.find('.js-note-edit').click end page.within(".current-note-edit-form") do diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index 46b3cb79af2..f2f999898db 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -2,7 +2,11 @@ module SharedPaths include Spinach::DSL include RepoHelpers include DashboardHelper +<<<<<<< HEAD include WaitForVueResource +======= + include WaitForRequests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 step 'I visit new project page' do visit new_project_path @@ -152,7 +156,7 @@ module SharedPaths visit profile_preferences_path end - step 'I visit Audit Log page' do + step 'I visit Authentication log page' do visit audit_log_profile_path end @@ -378,28 +382,48 @@ module SharedPaths step 'I visit merge request page "Bug NS-04"' do visit merge_request_path("Bug NS-04") +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I visit merge request page "Bug NS-05"' do visit merge_request_path("Bug NS-05") +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I visit merge request page "Bug NS-07"' do visit merge_request_path("Bug NS-07") +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I visit merge request page "Bug NS-08"' do visit merge_request_path("Bug NS-08") +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I visit merge request page "Bug CO-01"' do mr = MergeRequest.find_by(title: "Bug CO-01") visit namespace_project_merge_request_path(mr.target_project.namespace, mr.target_project, mr) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end step 'I visit project "Shop" merge requests page' do diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb index 15625e045f5..c4f1c57836f 100644 --- a/features/steps/shared/project.rb +++ b/features/steps/shared/project.rb @@ -256,9 +256,9 @@ module SharedProject end step 'I should see last commit with CI status' do - page.within ".project-last-commit" do + page.within ".blob-commit-info" do expect(page).to have_content(project.commit.sha[0..6]) - expect(page).to have_content("skipped") + expect(page).to have_link("Commit: skipped") end end diff --git a/features/steps/snippets/snippets.rb b/features/steps/snippets/snippets.rb index 0b3e942a4fd..a4fc77746ee 100644 --- a/features/steps/snippets/snippets.rb +++ b/features/steps/snippets/snippets.rb @@ -3,7 +3,7 @@ class Spinach::Features::Snippets < Spinach::FeatureSteps include SharedPaths include SharedProject include SharedSnippet - include WaitForAjax + include WaitForRequests step 'I click link "Personal snippet one"' do click_link "Personal snippet one" @@ -30,7 +30,7 @@ class Spinach::Features::Snippets < Spinach::FeatureSteps find('.ace_editor').native.send_keys 'Content of snippet three' end click_button "Create snippet" - wait_for_ajax + wait_for_requests end step 'I submit new internal snippet' do diff --git a/features/support/env.rb b/features/support/env.rb index 23a1f702068..9c8897152d3 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -10,7 +10,11 @@ if ENV['CI'] Knapsack::Adapters::SpinachAdapter.bind end +<<<<<<< HEAD %w(select2_helper test_env repo_helpers wait_for_ajax wait_for_requests sidekiq wait_for_vue_resource).each do |f| +======= +%w(select2_helper test_env repo_helpers wait_for_requests sidekiq).each do |f| +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 require Rails.root.join('spec', 'support', f) end @@ -33,7 +37,7 @@ end Spinach.hooks.after_scenario do |scenario_data, step_definitions| if scenario_data.tags.include?('javascript') include WaitForRequests - wait_for_requests_complete + block_and_wait_for_requests_complete end end diff --git a/lib/api/api.rb b/lib/api/api.rb index 6f5f4283937..a7425e441da 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -45,10 +45,14 @@ module API end before { allow_access_with_scope :api } +<<<<<<< HEAD before { header['X-Frame-Options'] = 'SAMEORIGIN' } before { Gitlab::I18n.set_locale(current_user) } +======= + before { Gitlab::I18n.locale = current_user&.preferred_language } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - after { Gitlab::I18n.reset_locale } + after { Gitlab::I18n.use_default_locale } rescue_from Gitlab::Access::AccessDeniedError do rack_response({ 'message' => '403 Forbidden' }.to_json, 403) @@ -95,6 +99,8 @@ module API mount ::API::DeployKeys mount ::API::Deployments mount ::API::Environments + mount ::API::Events + mount ::API::Features mount ::API::Files mount ::API::Groups mount ::API::Internal @@ -111,6 +117,7 @@ module API mount ::API::Notes mount ::API::NotificationSettings mount ::API::Pipelines + mount ::API::PipelineSchedules mount ::API::ProjectHooks mount ::API::Projects mount ::API::ProjectSnippets diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb index 827a38d33da..10f2d5ef6a3 100644 --- a/lib/api/commit_statuses.rb +++ b/lib/api/commit_statuses.rb @@ -68,7 +68,14 @@ module API name = params[:name] || params[:context] || 'default' - pipeline = @project.ensure_pipeline(ref, commit.sha, current_user) + pipeline = @project.pipeline_for(ref, commit.sha) + unless pipeline + pipeline = @project.pipelines.create!( + source: :external, + sha: commit.sha, + ref: ref, + user: current_user) + end status = GenericCommitStatus.running_or_pending.find_or_initialize_by( project: @project, diff --git a/lib/api/commits.rb b/lib/api/commits.rb index 621b9dcecd9..c6fc17cc391 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -176,7 +176,7 @@ module API } if params[:path] - commit.raw_diffs(all_diffs: true).each do |diff| + commit.raw_diffs(limits: false).each do |diff| next unless diff.new_path == params[:path] lines = Gitlab::Diff::Parser.new.parse(diff.diff.each_line) diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb index 8a54f7f3f05..7cdee8aced7 100644 --- a/lib/api/deploy_keys.rb +++ b/lib/api/deploy_keys.rb @@ -76,6 +76,27 @@ module API end end + desc 'Update an existing deploy key for a project' do + success Entities::SSHKey + end + params do + requires :key_id, type: Integer, desc: 'The ID of the deploy key' + optional :title, type: String, desc: 'The name of the deploy key' + optional :can_push, type: Boolean, desc: "Can deploy key push to the project's repository" + at_least_one_of :title, :can_push + end + put ":id/deploy_keys/:key_id" do + key = user_project.deploy_keys.find(params.delete(:key_id)) + + authorize!(:update_deploy_key, key) + + if key.update_attributes(declared_params(include_missing: false)) + present key, with: Entities::SSHKey + else + render_validation_error!(key) + end + end + desc 'Enable a deploy key for a project' do detail 'This feature was added in GitLab 8.11' success Entities::SSHKey diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 9f8304f7690..a836df3dc81 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -5,7 +5,10 @@ module API end class UserBasic < UserSafe - expose :id, :state, :avatar_url + expose :id, :state + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :web_url do |user, options| Gitlab::Routing.url_helpers.user_url(user) @@ -50,14 +53,14 @@ module API end class Hook < Grape::Entity - expose :id, :url, :created_at, :push_events, :tag_push_events + expose :id, :url, :created_at, :push_events, :tag_push_events, :repository_update_events expose :enable_ssl_verification end class ProjectHook < Hook expose :project_id, :issues_events, :merge_requests_events expose :note_events, :pipeline_events, :wiki_page_events - expose :build_events, as: :job_events + expose :job_events end class BasicProjectDetails < Grape::Entity @@ -97,7 +100,11 @@ module API expose :creator_id expose :namespace, using: 'API::Entities::Namespace' expose :forked_from_project, using: Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? } - expose :avatar_url + expose :import_status + expose :import_error, if: lambda { |_project, options| options[:user_can_admin_project] } + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :star_count, :forks_count expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:current_user]) && project.default_issues_tracker? } expose :runners_token, if: lambda { |_project, options| options[:user_can_admin_project] } @@ -141,7 +148,9 @@ module API class Group < Grape::Entity expose :id, :name, :path, :description, :visibility expose :lfs_enabled?, as: :lfs_enabled - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :web_url expose :request_access_enabled expose :full_name, :full_path @@ -217,7 +226,7 @@ module API end class ProjectSnippet < Grape::Entity - expose :id, :title, :file_name + expose :id, :title, :file_name, :description expose :author, using: Entities::UserBasic expose :updated_at, :created_at @@ -227,7 +236,7 @@ module API end class PersonalSnippet < Grape::Entity - expose :id, :title, :file_name + expose :id, :title, :file_name, :description expose :author, using: Entities::UserBasic expose :updated_at, :created_at @@ -248,7 +257,9 @@ module API class RepoDiff < Grape::Entity expose :old_path, :new_path, :a_mode, :b_mode, :diff - expose :new_file, :renamed_file, :deleted_file + expose :new_file?, as: :new_file + expose :renamed_file?, as: :renamed_file + expose :deleted_file?, as: :deleted_file end class Milestone < ProjectEntity @@ -322,7 +333,7 @@ module API class MergeRequestChanges < MergeRequest expose :diffs, as: :changes, using: Entities::RepoDiff do |compare, _| - compare.raw_diffs(all_diffs: true).to_a + compare.raw_diffs(limits: false).to_a end end @@ -335,7 +346,7 @@ module API expose :commits, using: Entities::RepoCommit expose :diffs, using: Entities::RepoDiff do |compare, _| - compare.raw_diffs(all_diffs: true).to_a + compare.raw_diffs(limits: false).to_a end end @@ -466,7 +477,7 @@ module API expose :id, :title, :created_at, :updated_at, :active expose :push_events, :issues_events, :merge_requests_events expose :tag_push_events, :note_events, :pipeline_events - expose :build_events, as: :job_events + expose :job_events # Expose serialized properties expose :properties do |service, options| field_names = service.fields. @@ -539,7 +550,7 @@ module API end expose :diffs, using: Entities::RepoDiff do |compare, options| - compare.diffs(all_diffs: true).to_a + compare.diffs(limits: false).to_a end expose :compare_timeout do |compare, options| @@ -666,6 +677,7 @@ module API class Variable < Grape::Entity expose :key, :value + expose :protected?, as: :protected end class Pipeline < PipelineBasic @@ -677,6 +689,17 @@ module API expose :coverage end + class PipelineSchedule < Grape::Entity + expose :id + expose :description, :ref, :cron, :cron_timezone, :next_run_at, :active + expose :created_at, :updated_at + expose :owner, using: Entities::UserBasic + end + + class PipelineScheduleDetails < PipelineSchedule + expose :last_pipeline, using: Entities::PipelineBasic + end + class EnvironmentBasic < Grape::Entity expose :id, :name, :slug, :external_url end @@ -733,6 +756,28 @@ module API expose :impersonation end + class FeatureGate < Grape::Entity + expose :key + expose :value + end + + class Feature < Grape::Entity + expose :name + expose :state + expose :gates, using: FeatureGate do |model| + model.gates.map do |gate| + value = model.gate_values[gate.key] + + # By default all gate values are populated. Only show relevant ones. + if (value.is_a?(Integer) && value.zero?) || (value.is_a?(Set) && value.empty?) + next + end + + { key: gate.key, value: value } + end.compact + end + end + module JobRequest class JobInfo < Grape::Entity expose :name, :stage diff --git a/lib/api/events.rb b/lib/api/events.rb new file mode 100644 index 00000000000..dabdf579119 --- /dev/null +++ b/lib/api/events.rb @@ -0,0 +1,86 @@ +module API + class Events < Grape::API + include PaginationParams + + helpers do + params :event_filter_params do + optional :action, type: String, values: Event.actions, desc: 'Event action to filter on' + optional :target_type, type: String, values: Event.target_types, desc: 'Event target type to filter on' + optional :before, type: Date, desc: 'Include only events created before this date' + optional :after, type: Date, desc: 'Include only events created after this date' + end + + params :sort_params do + optional :sort, type: String, values: %w[asc desc], default: 'desc', + desc: 'Return events sorted in ascending and descending order' + end + + def present_events(events) + events = events.reorder(created_at: params[:sort]) + + present paginate(events), with: Entities::Event + end + end + + resource :events do + desc "List currently authenticated user's events" do + detail 'This feature was introduced in GitLab 9.3.' + success Entities::Event + end + params do + use :pagination + use :event_filter_params + use :sort_params + end + get do + authenticate! + + events = EventsFinder.new(params.merge(source: current_user, current_user: current_user)).execute.preload(:author, :target) + + present_events(events) + end + end + + params do + requires :id, type: String, desc: 'The ID or Username of the user' + end + resource :users do + desc 'Get the contribution events of a specified user' do + detail 'This feature was introduced in GitLab 8.13.' + success Entities::Event + end + params do + use :pagination + use :event_filter_params + use :sort_params + end + get ':id/events' do + user = find_user(params[:id]) + not_found!('User') unless user + + events = EventsFinder.new(params.merge(source: user, current_user: current_user)).execute.preload(:author, :target) + + present_events(events) + end + end + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: { id: %r{[^/]+} } do + desc "List a Project's visible events" do + success Entities::Event + end + params do + use :pagination + use :event_filter_params + use :sort_params + end + get ":id/events" do + events = EventsFinder.new(params.merge(source: user_project, current_user: current_user)).execute.preload(:author, :target) + + present_events(events) + end + end + end +end diff --git a/lib/api/features.rb b/lib/api/features.rb new file mode 100644 index 00000000000..cff0ba2ddff --- /dev/null +++ b/lib/api/features.rb @@ -0,0 +1,36 @@ +module API + class Features < Grape::API + before { authenticated_as_admin! } + + resource :features do + desc 'Get a list of all features' do + success Entities::Feature + end + get do + features = Feature.all + + present features, with: Entities::Feature, current_user: current_user + end + + desc 'Set the gate value for the given feature' do + success Entities::Feature + end + params do + requires :value, type: String, desc: '`true` or `false` to enable/disable, an integer for percentage of time' + end + post ':name' do + feature = Feature.get(params[:name]) + + if %w(0 false).include?(params[:value]) + feature.disable + elsif params[:value] == 'true' + feature.enable + else + feature.enable_percentage_of_time(params[:value].to_i) + end + + present feature, with: Entities::Feature, current_user: current_user + end + end + end +end diff --git a/lib/api/files.rb b/lib/api/files.rb index e6ea12c5ab7..25b0968a271 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -10,7 +10,8 @@ module API file_content: attrs[:content], file_content_encoding: attrs[:encoding], author_email: attrs[:author_email], - author_name: attrs[:author_name] + author_name: attrs[:author_name], + last_commit_sha: attrs[:last_commit_id] } end @@ -46,6 +47,7 @@ module API use :simple_file_params requires :content, type: String, desc: 'File content' optional :encoding, type: String, values: %w[base64], desc: 'File encoding' + optional :last_commit_id, type: String, desc: 'Last known commit id for this file' end end @@ -111,7 +113,12 @@ module API authorize! :push_code, user_project file_params = declared_params(include_missing: false) - result = ::Files::UpdateService.new(user_project, current_user, commit_params(file_params)).execute + + begin + result = ::Files::UpdateService.new(user_project, current_user, commit_params(file_params)).execute + rescue ::Files::UpdateService::FileChangedError => e + render_api_error!(e.message, 400) + end if result[:status] == :success status(200) diff --git a/lib/api/groups.rb b/lib/api/groups.rb index 2c09725601e..ebbaed0cbb7 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -24,7 +24,7 @@ module API def present_groups(groups, options = {}) options = options.reverse_merge( with: Entities::Group, - current_user: current_user, + current_user: current_user ) groups = groups.with_statistics if options[:statistics] @@ -83,7 +83,7 @@ module API group = ::Groups::CreateService.new(current_user, declared_params(include_missing: false)).execute if group.persisted? - present group, with: Entities::Group, current_user: current_user + present group, with: Entities::GroupDetail, current_user: current_user else render_api_error!("Failed to save group #{group.errors.messages}", 400) end @@ -101,8 +101,6 @@ module API optional :name, type: String, desc: 'The name of the group' optional :path, type: String, desc: 'The path of the group' use :optional_params - at_least_one_of :name, :path, :description, :visibility, - :lfs_enabled, :request_access_enabled end put ':id' do group = find_group!(params[:id]) @@ -151,8 +149,8 @@ module API end get ":id/projects" do group = find_group!(params[:id]) - projects = GroupProjectsFinder.new(group: group, current_user: current_user).execute - projects = filter_projects(projects) + projects = GroupProjectsFinder.new(group: group, current_user: current_user, params: project_finder_params).execute + projects = reorder_projects(projects) entity = params[:simple] ? Entities::BasicProjectDetails : Entities::Project present paginate(projects), with: entity, current_user: current_user end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index c643ea8e5a7..2c73a6fdc4e 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -158,7 +158,7 @@ module API params_hash = custom_params || params attrs = {} keys.each do |key| - if params_hash[key].present? || (params_hash.has_key?(key) && params_hash[key] == false) + if params_hash[key].present? || (params_hash.key?(key) && params_hash[key] == false) attrs[key] = params_hash[key] end end @@ -256,31 +256,21 @@ module API # project helpers - def filter_projects(projects) - if params[:membership] - projects = projects.merge(current_user.authorized_projects) - end - - if params[:owned] - projects = projects.merge(current_user.owned_projects) - end - - if params[:starred] - projects = projects.merge(current_user.starred_projects) - end - - if params[:search].present? - projects = projects.search(params[:search]) - end - - if params[:visibility].present? - projects = projects.search_by_visibility(params[:visibility]) - end - - projects = projects.where(archived: params[:archived]) + def reorder_projects(projects) projects.reorder(params[:order_by] => params[:sort]) end + def project_finder_params + finder_params = {} + finder_params[:owned] = true if params[:owned].present? + finder_params[:non_public] = true if params[:membership].present? + finder_params[:starred] = true if params[:starred].present? + finder_params[:visibility_level] = Gitlab::VisibilityLevel.level_value(params[:visibility]) if params[:visibility] + finder_params[:archived] = params[:archived] + finder_params[:search] = params[:search] if params[:search] + finder_params + end + # file helpers def uploaded_file(field, uploads_path) @@ -301,7 +291,7 @@ module API UploadedFile.new( file_path, params["#{field}.name"], - params["#{field}.type"] || 'application/octet-stream', + params["#{field}.type"] || 'application/octet-stream' ) end @@ -321,6 +311,16 @@ module API end end + def present_artifacts!(artifacts_file) + return not_found! unless artifacts_file.exists? + + if artifacts_file.file_storage? + present_file!(artifacts_file.path, artifacts_file.filename) + else + redirect_to(artifacts_file.url) + end + end + private def private_token diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index 264df7271a3..d3732d67622 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -42,6 +42,22 @@ module API @project, @wiki = Gitlab::RepoPath.parse(params[:project]) end end + + # Project id to pass between components that don't share/don't have + # access to the same filesystem mounts + def gl_repository + Gitlab::GlRepository.gl_repository(project, wiki?) + end + + # Return the repository full path so that gitlab-shell has it when + # handling ssh commands + def repository_path + if wiki? + project.wiki.repository.path_to_repo + else + project.repository.path_to_repo + end + end end end end diff --git a/lib/api/internal.rb b/lib/api/internal.rb index 2971887770b..38631953014 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -32,31 +32,23 @@ module API actor.update_last_used_at if actor.is_a?(Key) - access_checker = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess - access_status = access_checker + access_checker_klass = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess + access_checker = access_checker_klass .new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities) - .check(params[:action], params[:changes]) - response = { status: access_status.status, message: access_status.message } - - if access_status.status - log_user_activity(actor) - - # Project id to pass between components that don't share/don't have - # access to the same filesystem mounts - response[:gl_repository] = Gitlab::GlRepository.gl_repository(project, wiki?) - - # Return the repository full path so that gitlab-shell has it when - # handling ssh commands - response[:repository_path] = - if wiki? - project.wiki.repository.path_to_repo - else - project.repository.path_to_repo - end + begin + access_checker.check(params[:action], params[:changes]) + rescue Gitlab::GitAccess::UnauthorizedError, Gitlab::GitAccess::NotFoundError => e + return { status: false, message: e.message } end - response + log_user_activity(actor) + + { + status: true, + gl_repository: gl_repository, + repository_path: repository_path + } end post "/lfs_authenticate" do @@ -90,7 +82,7 @@ module API { api_version: API.version, gitlab_version: Gitlab::VERSION, - gitlab_rev: Gitlab::REVISION, + gitlab_rev: Gitlab::REVISION } end diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb index 0223957fde1..8a67de10bca 100644 --- a/lib/api/jobs.rb +++ b/lib/api/jobs.rb @@ -224,16 +224,6 @@ module API find_build(id) || not_found! end - def present_artifacts!(artifacts_file) - if !artifacts_file.file_storage? - redirect_to(build.artifacts_file.url) - elsif artifacts_file.exists? - present_file!(artifacts_file.path, artifacts_file.filename) - else - not_found! - end - end - def filter_builds(builds, scope) return builds if scope.nil? || scope.empty? diff --git a/lib/api/pipeline_schedules.rb b/lib/api/pipeline_schedules.rb new file mode 100644 index 00000000000..93d89209934 --- /dev/null +++ b/lib/api/pipeline_schedules.rb @@ -0,0 +1,131 @@ +module API + class PipelineSchedules < Grape::API + include PaginationParams + + before { authenticate! } + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: { id: %r{[^/]+} } do + desc 'Get all pipeline schedules' do + success Entities::PipelineSchedule + end + params do + use :pagination + optional :scope, type: String, values: %w[active inactive], + desc: 'The scope of pipeline schedules' + end + get ':id/pipeline_schedules' do + authorize! :read_pipeline_schedule, user_project + + schedules = PipelineSchedulesFinder.new(user_project).execute(scope: params[:scope]) + .preload([:owner, :last_pipeline]) + present paginate(schedules), with: Entities::PipelineSchedule + end + + desc 'Get a single pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + end + get ':id/pipeline_schedules/:pipeline_schedule_id' do + authorize! :read_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + present pipeline_schedule, with: Entities::PipelineScheduleDetails + end + + desc 'Create a new pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :description, type: String, desc: 'The description of pipeline schedule' + requires :ref, type: String, desc: 'The branch/tag name will be triggered' + requires :cron, type: String, desc: 'The cron' + optional :cron_timezone, type: String, default: 'UTC', desc: 'The timezone' + optional :active, type: Boolean, default: true, desc: 'The activation of pipeline schedule' + end + post ':id/pipeline_schedules' do + authorize! :create_pipeline_schedule, user_project + + pipeline_schedule = Ci::CreatePipelineScheduleService + .new(user_project, current_user, declared_params(include_missing: false)) + .execute + + if pipeline_schedule.persisted? + present pipeline_schedule, with: Entities::PipelineScheduleDetails + else + render_validation_error!(pipeline_schedule) + end + end + + desc 'Edit a pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + optional :description, type: String, desc: 'The description of pipeline schedule' + optional :ref, type: String, desc: 'The branch/tag name will be triggered' + optional :cron, type: String, desc: 'The cron' + optional :cron_timezone, type: String, desc: 'The timezone' + optional :active, type: Boolean, desc: 'The activation of pipeline schedule' + end + put ':id/pipeline_schedules/:pipeline_schedule_id' do + authorize! :update_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + if pipeline_schedule.update(declared_params(include_missing: false)) + present pipeline_schedule, with: Entities::PipelineScheduleDetails + else + render_validation_error!(pipeline_schedule) + end + end + + desc 'Take ownership of a pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + end + post ':id/pipeline_schedules/:pipeline_schedule_id/take_ownership' do + authorize! :update_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + if pipeline_schedule.own!(current_user) + present pipeline_schedule, with: Entities::PipelineScheduleDetails + else + render_validation_error!(pipeline_schedule) + end + end + + desc 'Delete a pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + end + delete ':id/pipeline_schedules/:pipeline_schedule_id' do + authorize! :admin_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + status :accepted + present pipeline_schedule.destroy, with: Entities::PipelineScheduleDetails + end + end + + helpers do + def pipeline_schedule + @pipeline_schedule ||= + user_project.pipeline_schedules + .preload(:owner, :last_pipeline) + .find_by(id: params.delete(:pipeline_schedule_id)) + end + end + end +end diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb index 9117704aa46..e505cae3992 100644 --- a/lib/api/pipelines.rb +++ b/lib/api/pipelines.rb @@ -47,7 +47,7 @@ module API new_pipeline = Ci::CreatePipelineService.new(user_project, current_user, declared_params(include_missing: false)) - .execute(ignore_skip_ci: true, save_on_errors: false) + .execute(:api, ignore_skip_ci: true, save_on_errors: false) if new_pipeline.persisted? present new_pipeline, with: Entities::Pipeline else diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb index 87dfd1573a4..7a345289617 100644 --- a/lib/api/project_hooks.rb +++ b/lib/api/project_hooks.rb @@ -54,7 +54,6 @@ module API end post ":id/hooks" do hook_params = declared_params(include_missing: false) - hook_params[:build_events] = hook_params.delete(:job_events) { false } hook = user_project.hooks.new(hook_params) @@ -78,7 +77,6 @@ module API hook = user_project.hooks.find(params.delete(:hook_id)) update_params = declared_params(include_missing: false) - update_params[:build_events] = update_params.delete(:job_events) if update_params[:job_events] if hook.update_attributes(update_params) present hook, with: Entities::ProjectHook diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb index 98bc9c28527..64efe82a937 100644 --- a/lib/api/project_snippets.rb +++ b/lib/api/project_snippets.rb @@ -49,6 +49,7 @@ module API requires :title, type: String, desc: 'The title of the snippet' requires :file_name, type: String, desc: 'The file name of the snippet' requires :code, type: String, desc: 'The content of the snippet' + optional :description, type: String, desc: 'The description of a snippet' requires :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The visibility of the snippet' @@ -77,6 +78,7 @@ module API optional :title, type: String, desc: 'The title of the snippet' optional :file_name, type: String, desc: 'The file name of the snippet' optional :code, type: String, desc: 'The content of the snippet' + optional :description, type: String, desc: 'The description of a snippet' optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The visibility of the snippet' diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 9a6cb43abf7..56046742e08 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -21,6 +21,7 @@ module API optional :request_access_enabled, type: Boolean, desc: 'Allow users to request member access' optional :only_allow_merge_if_pipeline_succeeds, type: Boolean, desc: 'Only allow to merge if builds succeed' optional :only_allow_merge_if_all_discussions_are_resolved, type: Boolean, desc: 'Only allow to merge if all discussions are resolved' + optional :tag_list, type: Array[String], desc: 'The list of tags for a project' end params :optional_params do @@ -58,6 +59,8 @@ module API optional :owned, type: Boolean, default: false, desc: 'Limit by owned by authenticated user' optional :starred, type: Boolean, default: false, desc: 'Limit by starred status' optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of' + optional :with_issues_enabled, type: Boolean, default: false, desc: 'Limit by enabled issues feature' + optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature' end params :create_params do @@ -65,16 +68,19 @@ module API optional :import_url, type: String, desc: 'URL from which the project is imported' end - def present_projects(projects, options = {}) + def present_projects(options = {}) + projects = ProjectsFinder.new(current_user: current_user, params: project_finder_params).execute + projects = reorder_projects(projects) + projects = projects.with_statistics if params[:statistics] + projects = projects.with_issues_enabled if params[:with_issues_enabled] + projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled] + options = options.reverse_merge( - with: Entities::Project, - current_user: current_user, - simple: params[:simple], + with: current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails, + statistics: params[:statistics], + current_user: current_user ) - - projects = filter_projects(projects) - projects = projects.with_statistics if options[:statistics] - options[:with] = Entities::BasicProjectDetails if options[:simple] + options[:with] = Entities::BasicProjectDetails if params[:simple] present paginate(projects), options end @@ -88,8 +94,7 @@ module API use :statistics_params end get do - entity = current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails - present_projects ProjectsFinder.new(current_user: current_user).execute, with: entity, statistics: params[:statistics] + present_projects end desc 'Create new project' do @@ -104,7 +109,7 @@ module API end post do attrs = declared_params(include_missing: false) - attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.has_key?(:jobs_enabled) + attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.key?(:jobs_enabled) project = ::Projects::CreateService.new(current_user, attrs).execute if project.saved? @@ -124,6 +129,7 @@ module API params do requires :name, type: String, desc: 'The name of the project' requires :user_id, type: Integer, desc: 'The ID of a user' + optional :path, type: String, desc: 'The path of the repository' optional :default_branch, type: String, desc: 'The default branch of the project' use :optional_params use :create_params @@ -161,16 +167,6 @@ module API user_can_admin_project: can?(current_user, :admin_project, user_project), statistics: params[:statistics] end - desc 'Get events for a single project' do - success Entities::Event - end - params do - use :pagination - end - get ":id/events" do - present paginate(user_project.events.recent), with: Entities::Event - end - desc 'Fork new project for the current user or provided namespace.' do success Entities::Project end @@ -225,8 +221,9 @@ module API :request_access_enabled, :shared_runners_enabled, :snippets_enabled, + :tag_list, :visibility, - :wiki_enabled, + :wiki_enabled ] optional :name, type: String, desc: 'The name of the project' optional :default_branch, type: String, desc: 'The default branch of the project' @@ -241,7 +238,7 @@ module API authorize! :rename_project, user_project if attrs[:name].present? authorize! :change_visibility_level, user_project if attrs[:visibility].present? - attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.has_key?(:jobs_enabled) + attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.key?(:jobs_enabled) result = ::Projects::UpdateService.new(user_project, current_user, attrs).execute diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index 8f16e532ecb..14d2bff9cb5 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -85,7 +85,7 @@ module API optional :sha, type: String, desc: 'The commit sha of the archive to be downloaded' optional :format, type: String, desc: 'The archive format' end - get ':id/repository/archive', requirements: { format: Gitlab::Regex.archive_formats_regex } do + get ':id/repository/archive', requirements: { format: Gitlab::PathRegex.archive_formats_regex } do begin send_git_archive user_project.repository, ref: params[:sha], format: params[:format] rescue diff --git a/lib/api/runner.rb b/lib/api/runner.rb index 6fbb02cb3aa..4552115b3e2 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -141,7 +141,7 @@ module API patch '/:id/trace' do job = authenticate_job! - error!('400 Missing header Content-Range', 400) unless request.headers.has_key?('Content-Range') + error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range') content_range = request.headers['Content-Range'] content_range = content_range.split('-') @@ -241,16 +241,7 @@ module API get '/:id/artifacts' do job = authenticate_job! - artifacts_file = job.artifacts_file - unless artifacts_file.file_storage? - return redirect_to job.artifacts_file.url - end - - unless artifacts_file.exists? - not_found! - end - - present_file!(artifacts_file.path, artifacts_file.filename) + present_artifacts!(job.artifacts_file) end end end diff --git a/lib/api/services.rb b/lib/api/services.rb index 23ef62c2258..47bd9940f77 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -304,7 +304,13 @@ module API required: true, name: :url, type: String, - desc: 'The URL to the JIRA project which is being linked to this GitLab project, e.g., https://jira.example.com' + desc: 'The base URL to the JIRA instance web interface which is being linked to this GitLab project. E.g., https://jira.example.com' + }, + { + required: false, + name: :api_url, + type: String, + desc: 'The base URL to the JIRA instance API. Web URL value will be used if not set. E.g., https://jira-api.example.com' }, { required: true, @@ -356,7 +362,7 @@ module API name: :ca_pem, type: String, desc: 'A custom certificate authority bundle to verify the Kubernetes cluster with (PEM format)' - }, + } ], 'mattermost-slash-commands' => [ { @@ -559,7 +565,7 @@ module API SlackService, MattermostService, MicrosoftTeamsService, - TeamcityService, + TeamcityService ] if Rails.env.development? @@ -577,7 +583,7 @@ module API service_classes += [ MockCiService, MockDeploymentService, - MockMonitoringService, + MockMonitoringService ] end diff --git a/lib/api/settings.rb b/lib/api/settings.rb index 82f513c984e..25027c3b114 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -110,6 +110,7 @@ module API optional :default_artifacts_expire_in, type: String, desc: "Set the default expiration time for each job's artifacts" optional :max_pages_size, type: Integer, desc: 'Maximum size of pages in MB' optional :container_registry_token_expire_delay, type: Integer, desc: 'Authorization token duration (minutes)' + optional :prometheus_metrics_enabled, type: Boolean, desc: 'Enable Prometheus metrics' optional :metrics_enabled, type: Boolean, desc: 'Enable the InfluxDB metrics' given metrics_enabled: ->(val) { val } do requires :metrics_host, type: String, desc: 'The InfluxDB host' diff --git a/lib/api/snippets.rb b/lib/api/snippets.rb index 53f5953a8fb..c630c24c339 100644 --- a/lib/api/snippets.rb +++ b/lib/api/snippets.rb @@ -58,6 +58,7 @@ module API requires :title, type: String, desc: 'The title of a snippet' requires :file_name, type: String, desc: 'The name of a snippet file' requires :content, type: String, desc: 'The content of a snippet' + optional :description, type: String, desc: 'The description of a snippet' optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, default: 'internal', @@ -85,6 +86,7 @@ module API optional :title, type: String, desc: 'The title of a snippet' optional :file_name, type: String, desc: 'The name of a snippet file' optional :content, type: String, desc: 'The content of a snippet' + optional :description, type: String, desc: 'The description of a snippet' optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The visibility of the snippet' diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb index dbe54d3cd31..91567909998 100644 --- a/lib/api/subscriptions.rb +++ b/lib/api/subscriptions.rb @@ -5,7 +5,7 @@ module API subscribable_types = { 'merge_requests' => proc { |id| find_merge_request_with_access(id, :update_merge_request) }, 'issues' => proc { |id| find_project_issue(id) }, - 'labels' => proc { |id| find_project_label(id) }, + 'labels' => proc { |id| find_project_label(id) } } params do diff --git a/lib/api/time_tracking_endpoints.rb b/lib/api/time_tracking_endpoints.rb index 05b4b490e27..df4632346dd 100644 --- a/lib/api/time_tracking_endpoints.rb +++ b/lib/api/time_tracking_endpoints.rb @@ -5,7 +5,7 @@ module API included do helpers do def issuable_name - declared_params.has_key?(:issue_iid) ? 'issue' : 'merge_request' + declared_params.key?(:issue_iid) ? 'issue' : 'merge_request' end def issuable_key diff --git a/lib/api/users.rb b/lib/api/users.rb index 40acaebf670..dda64715ee1 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -56,16 +56,7 @@ module API authenticated_as_admin! if params[:external].present? || (params[:extern_uid].present? && params[:provider].present?) - users = User.all - users = User.where(username: params[:username]) if params[:username] - users = users.active if params[:active] - users = users.search(params[:search]) if params[:search].present? - users = users.blocked if params[:blocked] - - if current_user.admin? - users = users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid])) if params[:extern_uid] && params[:provider] - users = users.external if params[:external] - end + users = UsersFinder.new(current_user, params).execute entity = current_user.admin? ? Entities::UserPublic : Entities::UserBasic present paginate(users), with: entity @@ -133,10 +124,6 @@ module API optional :name, type: String, desc: 'The name of the user' optional :username, type: String, desc: 'The username of the user' use :optional_attributes - at_least_one_of :email, :password, :name, :username, :skype, :linkedin, - :twitter, :website_url, :organization, :projects_limit, - :extern_uid, :provider, :bio, :location, :admin, - :can_create_group, :confirm, :external end put ":id" do authenticated_as_admin! @@ -295,13 +282,14 @@ module API end params do requires :id, type: Integer, desc: 'The ID of the user' + optional :hard_delete, type: Boolean, desc: "Whether to remove a user's contributions" end delete ":id" do authenticated_as_admin! user = User.find_by(id: params[:id]) not_found!('User') unless user - DeleteUserWorker.perform_async(current_user.id, user.id) + user.delete_async(deleted_by: current_user, params: params) end desc 'Block a user. Available only for admins.' @@ -336,27 +324,6 @@ module API end end - desc 'Get the 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: Integer, desc: 'The ID of the user' - use :pagination - end - get ':id/events' do - user = User.find_by(id: params[:id]) - not_found!('User') unless user - - events = user.events. - merge(ProjectsFinder.new(current_user: current_user).execute). - references(:project). - with_associations. - recent - - present paginate(events), with: Entities::Event - end - params do requires :user_id, type: Integer, desc: 'The ID of the user' end diff --git a/lib/api/v3/builds.rb b/lib/api/v3/builds.rb index 21935922414..93ad9eb26b8 100644 --- a/lib/api/v3/builds.rb +++ b/lib/api/v3/builds.rb @@ -225,16 +225,6 @@ module API find_build(id) || not_found! end - def present_artifacts!(artifacts_file) - if !artifacts_file.file_storage? - redirect_to(build.artifacts_file.url) - elsif artifacts_file.exists? - present_file!(artifacts_file.path, artifacts_file.filename) - else - not_found! - end - end - def filter_builds(builds, scope) return builds if scope.nil? || scope.empty? diff --git a/lib/api/v3/commits.rb b/lib/api/v3/commits.rb index 674de592f0a..5936f4700aa 100644 --- a/lib/api/v3/commits.rb +++ b/lib/api/v3/commits.rb @@ -167,7 +167,7 @@ module API } if params[:path] - commit.raw_diffs(all_diffs: true).each do |diff| + commit.raw_diffs(limits: false).each do |diff| next unless diff.new_path == params[:path] lines = Gitlab::Diff::Parser.new.parse(diff.diff.each_line) diff --git a/lib/api/v3/deploy_keys.rb b/lib/api/v3/deploy_keys.rb index bbb174b6003..b90e2061da3 100644 --- a/lib/api/v3/deploy_keys.rb +++ b/lib/api/v3/deploy_keys.rb @@ -41,6 +41,7 @@ module API params do requires :key, type: String, desc: 'The new deploy key' requires :title, type: String, desc: 'The name of the deploy key' + optional :can_push, type: Boolean, desc: "Can deploy key push to the project's repository" end post ":id/#{path}" do params[:key].strip! diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 1c08e25c00c..7c5065dee90 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -69,7 +69,9 @@ module API expose :creator_id expose :namespace, using: 'API::Entities::Namespace' expose :forked_from_project, using: ::API::Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? } - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :star_count, :forks_count expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:current_user]) && project.default_issues_tracker? } expose :runners_token, if: lambda { |_project, options| options[:user_can_admin_project] } @@ -129,7 +131,9 @@ module API class Group < Grape::Entity expose :id, :name, :path, :description, :visibility_level expose :lfs_enabled?, as: :lfs_enabled - expose :avatar_url + expose :avatar_url do |user, options| + user.avatar_url(only_path: false) + end expose :web_url expose :request_access_enabled expose :full_name, :full_path @@ -222,7 +226,7 @@ module API class MergeRequestChanges < MergeRequest expose :diffs, as: :changes, using: ::API::Entities::RepoDiff do |compare, _| - compare.raw_diffs(all_diffs: true).to_a + compare.raw_diffs(limits: false).to_a end end @@ -237,7 +241,8 @@ module API class ProjectService < Grape::Entity expose :id, :title, :created_at, :updated_at, :active expose :push_events, :issues_events, :merge_requests_events - expose :tag_push_events, :note_events, :build_events, :pipeline_events + expose :tag_push_events, :note_events, :pipeline_events + expose :job_events, as: :build_events # Expose serialized properties expose :properties do |service, options| field_names = service.fields. @@ -249,7 +254,8 @@ module API class ProjectHook < ::API::Entities::Hook expose :project_id, :issues_events, :merge_requests_events - expose :note_events, :build_events, :pipeline_events, :wiki_page_events + expose :note_events, :pipeline_events, :wiki_page_events + expose :job_events, as: :build_events end class Issue < ::API::Entities::Issue diff --git a/lib/api/v3/groups.rb b/lib/api/v3/groups.rb index 42922df6e29..2c52d21fa1c 100644 --- a/lib/api/v3/groups.rb +++ b/lib/api/v3/groups.rb @@ -20,7 +20,7 @@ module API def present_groups(groups, options = {}) options = options.reverse_merge( with: Entities::Group, - current_user: current_user, + current_user: current_user ) groups = groups.with_statistics if options[:statistics] diff --git a/lib/api/v3/helpers.rb b/lib/api/v3/helpers.rb index 0f234d4cdad..d9e76560d03 100644 --- a/lib/api/v3/helpers.rb +++ b/lib/api/v3/helpers.rb @@ -14,6 +14,33 @@ module API authorize! access_level, merge_request merge_request end + + # project helpers + + def filter_projects(projects) + if params[:membership] + projects = projects.merge(current_user.authorized_projects) + end + + if params[:owned] + projects = projects.merge(current_user.owned_projects) + end + + if params[:starred] + projects = projects.merge(current_user.starred_projects) + end + + if params[:search].present? + projects = projects.search(params[:search]) + end + + if params[:visibility].present? + projects = projects.where(visibility_level: Gitlab::VisibilityLevel.level_value(params[:visibility])) + end + + projects = projects.where(archived: params[:archived]) + projects.reorder(params[:order_by] => params[:sort]) + end end end end diff --git a/lib/api/v3/projects.rb b/lib/api/v3/projects.rb index 06cc704afc6..20976b9dd08 100644 --- a/lib/api/v3/projects.rb +++ b/lib/api/v3/projects.rb @@ -44,7 +44,7 @@ module API end def set_only_allow_merge_if_pipeline_succeeds! - if params.has_key?(:only_allow_merge_if_build_succeeds) + if params.key?(:only_allow_merge_if_build_succeeds) params[:only_allow_merge_if_pipeline_succeeds] = params.delete(:only_allow_merge_if_build_succeeds) end end @@ -88,7 +88,7 @@ module API options = options.reverse_merge( with: ::API::V3::Entities::Project, current_user: current_user, - simple: params[:simple], + simple: params[:simple] ) projects = filter_projects(projects) @@ -147,7 +147,7 @@ module API get '/starred' do authenticate! - present_projects current_user.viewable_starred_projects + present_projects ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute end desc 'Get all projects for admin user' do diff --git a/lib/api/v3/repositories.rb b/lib/api/v3/repositories.rb index e4d14bc8168..0eaa0de2eef 100644 --- a/lib/api/v3/repositories.rb +++ b/lib/api/v3/repositories.rb @@ -72,7 +72,7 @@ module API optional :sha, type: String, desc: 'The commit sha of the archive to be downloaded' optional :format, type: String, desc: 'The archive format' end - get ':id/repository/archive', requirements: { format: Gitlab::Regex.archive_formats_regex } do + get ':id/repository/archive', requirements: { format: Gitlab::PathRegex.archive_formats_regex } do begin send_git_archive user_project.repository, ref: params[:sha], format: params[:format] rescue diff --git a/lib/api/v3/services.rb b/lib/api/v3/services.rb index 61629a04174..118c6df6549 100644 --- a/lib/api/v3/services.rb +++ b/lib/api/v3/services.rb @@ -377,7 +377,7 @@ module API name: :ca_pem, type: String, desc: 'A custom certificate authority bundle to verify the Kubernetes cluster with (PEM format)' - }, + } ], 'mattermost-slash-commands' => [ { diff --git a/lib/api/v3/subscriptions.rb b/lib/api/v3/subscriptions.rb index 068750ec077..690768db82f 100644 --- a/lib/api/v3/subscriptions.rb +++ b/lib/api/v3/subscriptions.rb @@ -7,7 +7,7 @@ module API 'merge_request' => proc { |id| find_merge_request_with_access(id, :update_merge_request) }, 'merge_requests' => proc { |id| find_merge_request_with_access(id, :update_merge_request) }, 'issues' => proc { |id| find_project_issue(id) }, - 'labels' => proc { |id| find_project_label(id) }, + 'labels' => proc { |id| find_project_label(id) } } params do diff --git a/lib/api/v3/time_tracking_endpoints.rb b/lib/api/v3/time_tracking_endpoints.rb index 81ae4e8137d..d5b90e435ba 100644 --- a/lib/api/v3/time_tracking_endpoints.rb +++ b/lib/api/v3/time_tracking_endpoints.rb @@ -6,7 +6,7 @@ module API included do helpers do def issuable_name - declared_params.has_key?(:issue_id) ? 'issue' : 'merge_request' + declared_params.key?(:issue_id) ? 'issue' : 'merge_request' end def issuable_key diff --git a/lib/api/variables.rb b/lib/api/variables.rb index 5acde41551b..381c4ef50b0 100644 --- a/lib/api/variables.rb +++ b/lib/api/variables.rb @@ -42,6 +42,7 @@ module API params do requires :key, type: String, desc: 'The key of the variable' requires :value, type: String, desc: 'The value of the variable' + optional :protected, type: String, desc: 'Whether the variable is protected' end post ':id/variables' do variable = user_project.variables.create(declared(params, include_parent_namespaces: false).to_h) @@ -59,13 +60,14 @@ module API params do optional :key, type: String, desc: 'The key of the variable' optional :value, type: String, desc: 'The value of the variable' + optional :protected, type: String, desc: 'Whether the variable is protected' end put ':id/variables/:key' do variable = user_project.variables.find_by(key: params[:key]) return not_found!('Variable') unless variable - if variable.update(value: params[:value]) + if variable.update(declared_params(include_missing: false).except(:key)) present variable, with: Entities::Variable else render_validation_error!(variable) diff --git a/lib/backup/artifacts.rb b/lib/backup/artifacts.rb index 51fa3867e67..1f4bda6f588 100644 --- a/lib/backup/artifacts.rb +++ b/lib/backup/artifacts.rb @@ -3,7 +3,7 @@ require 'backup/files' module Backup class Artifacts < Files def initialize - super('artifacts', ArtifactUploader.artifacts_path) + super('artifacts', ArtifactUploader.local_artifacts_store) end def create_files_dir diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb index 6b29600a751..a1685c77916 100644 --- a/lib/backup/repository.rb +++ b/lib/backup/repository.rb @@ -7,15 +7,15 @@ module Backup prepare Project.find_each(batch_size: 1000) do |project| - $progress.print " * #{project.path_with_namespace} ... " + progress.print " * #{project.path_with_namespace} ... " path_to_project_repo = path_to_repo(project) path_to_project_bundle = path_to_bundle(project) # Create namespace dir if missing FileUtils.mkdir_p(File.join(backup_repos_path, project.namespace.full_path)) if project.namespace - if project.empty_repo? - $progress.puts "[SKIPPED]".color(:cyan) + if empty_repo?(project) + progress.puts "[SKIPPED]".color(:cyan) else in_path(path_to_project_repo) do |dir| FileUtils.mkdir_p(path_to_tars(project)) @@ -23,10 +23,7 @@ module Backup output, status = Gitlab::Popen.popen(cmd) unless status.zero? - puts "[FAILED]".color(:red) - puts "failed: #{cmd.join(' ')}" - puts output - abort 'Backup failed' + progress_warn(project, cmd.join(' '), output) end end @@ -34,12 +31,9 @@ module Backup output, status = Gitlab::Popen.popen(cmd) if status.zero? - $progress.puts "[DONE]".color(:green) + progress.puts "[DONE]".color(:green) else - puts "[FAILED]".color(:red) - puts "failed: #{cmd.join(' ')}" - puts output - abort 'Backup failed' + progress_warn(project, cmd.join(' '), output) end end @@ -48,19 +42,16 @@ module Backup path_to_wiki_bundle = path_to_bundle(wiki) if File.exist?(path_to_wiki_repo) - $progress.print " * #{wiki.path_with_namespace} ... " - if wiki.repository.empty? - $progress.puts " [SKIPPED]".color(:cyan) + progress.print " * #{wiki.path_with_namespace} ... " + if empty_repo?(wiki) + progress.puts " [SKIPPED]".color(:cyan) else cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path_to_wiki_repo} bundle create #{path_to_wiki_bundle} --all) output, status = Gitlab::Popen.popen(cmd) if status.zero? - $progress.puts " [DONE]".color(:green) + progress.puts " [DONE]".color(:green) else - puts " [FAILED]".color(:red) - puts "failed: #{cmd.join(' ')}" - puts output - abort 'Backup failed' + progress_warn(wiki, cmd.join(' '), output) end end end @@ -80,7 +71,7 @@ module Backup end Project.find_each(batch_size: 1000) do |project| - $progress.print " * #{project.path_with_namespace} ... " + progress.print " * #{project.path_with_namespace} ... " path_to_project_repo = path_to_repo(project) path_to_project_bundle = path_to_bundle(project) @@ -94,12 +85,9 @@ module Backup output, status = Gitlab::Popen.popen(cmd) if status.zero? - $progress.puts "[DONE]".color(:green) + progress.puts "[DONE]".color(:green) else - puts "[FAILED]".color(:red) - puts "failed: #{cmd.join(' ')}" - puts output - abort 'Restore failed' + progress_warn(project, cmd.join(' '), output) end in_path(path_to_tars(project)) do |dir| @@ -107,10 +95,7 @@ module Backup output, status = Gitlab::Popen.popen(cmd) unless status.zero? - puts "[FAILED]".color(:red) - puts "failed: #{cmd.join(' ')}" - puts output - abort 'Restore failed' + progress_warn(project, cmd.join(' '), output) end end @@ -119,7 +104,7 @@ module Backup path_to_wiki_bundle = path_to_bundle(wiki) if File.exist?(path_to_wiki_bundle) - $progress.print " * #{wiki.path_with_namespace} ... " + progress.print " * #{wiki.path_with_namespace} ... " # If a wiki bundle exists, first remove the empty repo # that was initialized with ProjectWiki.new() and then @@ -129,22 +114,19 @@ module Backup output, status = Gitlab::Popen.popen(cmd) if status.zero? - $progress.puts " [DONE]".color(:green) + progress.puts " [DONE]".color(:green) else - puts " [FAILED]".color(:red) - puts "failed: #{cmd.join(' ')}" - puts output - abort 'Restore failed' + progress_warn(project, cmd.join(' '), output) end end end - $progress.print 'Put GitLab hooks in repositories dirs'.color(:yellow) + progress.print 'Put GitLab hooks in repositories dirs'.color(:yellow) cmd = %W(#{Gitlab.config.gitlab_shell.path}/bin/create-hooks) + repository_storage_paths_args output, status = Gitlab::Popen.popen(cmd) if status.zero? - $progress.puts " [DONE]".color(:green) + progress.puts " [DONE]".color(:green) else puts " [FAILED]".color(:red) puts "failed: #{cmd}" @@ -201,8 +183,25 @@ module Backup private + def progress_warn(project, cmd, output) + progress.puts "[WARNING] Executing #{cmd}".color(:orange) + progress.puts "Ignoring error on #{project.path_with_namespace} - #{output}".color(:orange) + end + + def empty_repo?(project_or_wiki) + project_or_wiki.repository.empty_repo? + rescue => e + progress.puts "Ignoring repository error and continuing backing up project: #{project_or_wiki.path_with_namespace} - #{e.message}".color(:orange) + + false + end + def repository_storage_paths_args Gitlab.config.repositories.storages.values.map { |rs| rs['path'] } end + + def progress + $progress + end end end diff --git a/lib/banzai/filter/ascii_doc_post_processing_filter.rb b/lib/banzai/filter/ascii_doc_post_processing_filter.rb new file mode 100644 index 00000000000..c9fcf057c5f --- /dev/null +++ b/lib/banzai/filter/ascii_doc_post_processing_filter.rb @@ -0,0 +1,13 @@ +module Banzai + module Filter + class AsciiDocPostProcessingFilter < HTML::Pipeline::Filter + def call + doc.search('[data-math-style]').each do |node| + node.set_attribute('class', 'code math js-render-math') + end + + doc + end + end + end +end diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb index 522217deae4..2d6e8ffc90f 100644 --- a/lib/banzai/filter/sanitization_filter.rb +++ b/lib/banzai/filter/sanitization_filter.rb @@ -31,6 +31,10 @@ module Banzai # Allow span elements whitelist[:elements].push('span') + # Allow data-math-style attribute in order to support LaTeX formatting + whitelist[:attributes]['code'] = %w(data-math-style) + whitelist[:attributes]['pre'] = %w(data-math-style) + # Allow html5 details/summary elements whitelist[:elements].push('details') whitelist[:elements].push('summary') diff --git a/lib/banzai/pipeline/ascii_doc_pipeline.rb b/lib/banzai/pipeline/ascii_doc_pipeline.rb new file mode 100644 index 00000000000..1048b927cd3 --- /dev/null +++ b/lib/banzai/pipeline/ascii_doc_pipeline.rb @@ -0,0 +1,14 @@ +module Banzai + module Pipeline + class AsciiDocPipeline < BasePipeline + def self.filters + FilterArray[ + Filter::SanitizationFilter, + Filter::ExternalLinkFilter, + Filter::PlantumlFilter, + Filter::AsciiDocPostProcessingFilter + ] + end + end + end +end diff --git a/lib/banzai/reference_parser/base_parser.rb b/lib/banzai/reference_parser/base_parser.rb index a20200c5879..1e2536231d8 100644 --- a/lib/banzai/reference_parser/base_parser.rb +++ b/lib/banzai/reference_parser/base_parser.rb @@ -163,14 +163,15 @@ module Banzai # been queried the object is returned from the cache. def collection_objects_for_ids(collection, ids) if RequestStore.active? + ids = ids.map(&:to_i) cache = collection_cache[collection_cache_key(collection)] - to_query = ids.map(&:to_i) - cache.keys + to_query = ids - cache.keys unless to_query.empty? collection.where(id: to_query).each { |row| cache[row.id] = row } end - cache.values + cache.values_at(*ids) else collection.where(id: ids) end diff --git a/lib/bitbucket/representation/pull_request_comment.rb b/lib/bitbucket/representation/pull_request_comment.rb index 4f8efe03bae..c52acbc3ddc 100644 --- a/lib/bitbucket/representation/pull_request_comment.rb +++ b/lib/bitbucket/representation/pull_request_comment.rb @@ -22,11 +22,11 @@ module Bitbucket end def inline? - raw.has_key?('inline') + raw.key?('inline') end def has_parent? - raw.has_key?('parent') + raw.key?('parent') end private diff --git a/lib/ci/ansi2html.rb b/lib/ci/ansi2html.rb index b439b0ee29b..55402101e43 100644 --- a/lib/ci/ansi2html.rb +++ b/lib/ci/ansi2html.rb @@ -20,7 +20,7 @@ module Ci italic: 0x02, underline: 0x04, conceal: 0x08, - cross: 0x10, + cross: 0x10 }.freeze def self.convert(ansi, state = nil) diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb index 67b269b330c..e2e91ce99cd 100644 --- a/lib/ci/api/builds.rb +++ b/lib/ci/api/builds.rb @@ -88,7 +88,7 @@ module Ci patch ":id/trace.txt" do build = authenticate_build! - error!('400 Missing header Content-Range', 400) unless request.headers.has_key?('Content-Range') + error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range') content_range = request.headers['Content-Range'] content_range = content_range.split('-') @@ -187,14 +187,14 @@ module Ci build = authenticate_build! artifacts_file = build.artifacts_file - unless artifacts_file.file_storage? - return redirect_to build.artifacts_file.url - end - unless artifacts_file.exists? not_found! end + unless artifacts_file.file_storage? + return redirect_to build.artifacts_file.url + end + present_file!(artifacts_file.path, artifacts_file.filename) end diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 15a461a16dd..56ad2c77c7d 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -20,26 +20,26 @@ module Ci raise ValidationError, e.message end - def jobs_for_ref(ref, tag = false, trigger_request = nil) + def jobs_for_ref(ref, tag = false, source = nil) @jobs.select do |_, job| - process?(job[:only], job[:except], ref, tag, trigger_request) + process?(job[:only], job[:except], ref, tag, source) end end - def jobs_for_stage_and_ref(stage, ref, tag = false, trigger_request = nil) - jobs_for_ref(ref, tag, trigger_request).select do |_, job| + def jobs_for_stage_and_ref(stage, ref, tag = false, source = nil) + jobs_for_ref(ref, tag, source).select do |_, job| job[:stage] == stage end end - def builds_for_ref(ref, tag = false, trigger_request = nil) - jobs_for_ref(ref, tag, trigger_request).map do |name, _| + def builds_for_ref(ref, tag = false, source = nil) + jobs_for_ref(ref, tag, source).map do |name, _| build_attributes(name) end end - def builds_for_stage_and_ref(stage, ref, tag = false, trigger_request = nil) - jobs_for_stage_and_ref(stage, ref, tag, trigger_request).map do |name, _| + def builds_for_stage_and_ref(stage, ref, tag = false, source = nil) + jobs_for_stage_and_ref(stage, ref, tag, source).map do |name, _| build_attributes(name) end end @@ -50,10 +50,21 @@ module Ci end end + def stage_seeds(pipeline) + seeds = @stages.uniq.map do |stage| + builds = builds_for_stage_and_ref( + stage, pipeline.ref, pipeline.tag?, pipeline.source) + + Gitlab::Ci::Stage::Seed.new(pipeline, stage, builds) if builds.any? + end + + seeds.compact + end + def build_attributes(name) job = @jobs[name.to_sym] || {} - { - stage_idx: @stages.index(job[:stage]), + + { stage_idx: @stages.index(job[:stage]), stage: job[:stage], commands: job[:commands], tag_list: job[:tags] || [], @@ -70,9 +81,8 @@ module Ci cache: job[:cache], dependencies: job[:dependencies], after_script: job[:after_script], - environment: job[:environment], - }.compact - } + environment: job[:environment] + }.compact } end def self.validation_message(content) @@ -181,30 +191,35 @@ module Ci end end - def process?(only_params, except_params, ref, tag, trigger_request) + def process?(only_params, except_params, ref, tag, source) if only_params.present? - return false unless matching?(only_params, ref, tag, trigger_request) + return false unless matching?(only_params, ref, tag, source) end if except_params.present? - return false if matching?(except_params, ref, tag, trigger_request) + return false if matching?(except_params, ref, tag, source) end true end - def matching?(patterns, ref, tag, trigger_request) + def matching?(patterns, ref, tag, source) patterns.any? do |pattern| - match_ref?(pattern, ref, tag, trigger_request) + pattern, path = pattern.split('@', 2) + matches_path?(path) && matches_pattern?(pattern, ref, tag, source) end end - def match_ref?(pattern, ref, tag, trigger_request) - pattern, path = pattern.split('@', 2) - return false if path && path != self.path + def matches_path?(path) + return true unless path + + path == self.path + end + + def matches_pattern?(pattern, ref, tag, source) return true if tag && pattern == 'tags' return true if !tag && pattern == 'branches' - return true if trigger_request.present? && pattern == 'triggers' + return true if source_to_pattern(source) == pattern if pattern.first == "/" && pattern.last == "/" Regexp.new(pattern[1...-1]) =~ ref @@ -212,5 +227,13 @@ module Ci pattern == ref end end + + def source_to_pattern(source) + if %w[api external web].include?(source) + source + else + source&.pluralize + end + end end end diff --git a/lib/constraints/group_url_constrainer.rb b/lib/constraints/group_url_constrainer.rb index 0ea2f97352d..d9755a26ff6 100644 --- a/lib/constraints/group_url_constrainer.rb +++ b/lib/constraints/group_url_constrainer.rb @@ -1,9 +1,15 @@ class GroupUrlConstrainer def matches?(request) +<<<<<<< HEAD id = request.params[:group_id] || request.params[:id] return false unless DynamicPathValidator.valid_namespace_path?(id) +======= + full_path = request.params[:group_id] || request.params[:id] - Group.find_by_full_path(id, follow_redirects: request.get?).present? + return false unless DynamicPathValidator.valid_group_path?(full_path) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + + Group.find_by_full_path(full_path, follow_redirects: request.get?).present? end end diff --git a/lib/constraints/project_url_constrainer.rb b/lib/constraints/project_url_constrainer.rb index 4444a1abee3..4c0aee6c48f 100644 --- a/lib/constraints/project_url_constrainer.rb +++ b/lib/constraints/project_url_constrainer.rb @@ -2,7 +2,7 @@ class ProjectUrlConstrainer def matches?(request) namespace_path = request.params[:namespace_id] project_path = request.params[:project_id] || request.params[:id] - full_path = namespace_path + '/' + project_path + full_path = [namespace_path, project_path].join('/') return false unless DynamicPathValidator.valid_project_path?(full_path) diff --git a/lib/constraints/user_url_constrainer.rb b/lib/constraints/user_url_constrainer.rb index 28159dc0dec..d16ae7f3f40 100644 --- a/lib/constraints/user_url_constrainer.rb +++ b/lib/constraints/user_url_constrainer.rb @@ -1,5 +1,9 @@ class UserUrlConstrainer def matches?(request) - User.find_by_full_path(request.params[:username], follow_redirects: request.get?).present? + full_path = request.params[:username] + + return false unless DynamicPathValidator.valid_user_path?(full_path) + + User.find_by_full_path(full_path, follow_redirects: request.get?).present? end end diff --git a/lib/container_registry/client.rb b/lib/container_registry/client.rb index 7f5f6d9ddb6..c7263f302ab 100644 --- a/lib/container_registry/client.rb +++ b/lib/container_registry/client.rb @@ -75,10 +75,7 @@ module ContainerRegistry def redirect_response(location) return unless location - # We explicitly remove authorization token - faraday_blob.get(location) do |req| - req['Authorization'] = '' - end + faraday_redirect.get(location) end def faraday @@ -93,5 +90,14 @@ module ContainerRegistry initialize_connection(conn, @options) end end + + # Create a new request to make sure the Authorization header is not inserted + # via the Faraday middleware + def faraday_redirect + @faraday_redirect ||= Faraday.new(@base_uri) do |conn| + conn.request :json + conn.adapter :net_http + end + end end end diff --git a/lib/feature.rb b/lib/feature.rb new file mode 100644 index 00000000000..5650a1c1334 --- /dev/null +++ b/lib/feature.rb @@ -0,0 +1,53 @@ +require 'flipper/adapters/active_record' + +class Feature + # Classes to override flipper table names + class FlipperFeature < Flipper::Adapters::ActiveRecord::Feature + # Using `self.table_name` won't work. ActiveRecord bug? + superclass.table_name = 'features' + end + + class FlipperGate < Flipper::Adapters::ActiveRecord::Gate + superclass.table_name = 'feature_gates' + end + + class << self + def all + flipper.features.to_a + end + + def get(key) + flipper.feature(key) + end + + def persisted?(feature) + # Flipper creates on-memory features when asked for a not-yet-created one. + # If we want to check if a feature has been actually set, we look for it + # on the persisted features list. + all.map(&:name).include?(feature.name) + end + + def enabled?(key) + get(key).enabled? + end + + def enable(key) + get(key).enable + end + + def disable(key) + get(key).disable + end + + private + + def flipper + @flipper ||= begin + adapter = Flipper::Adapters::ActiveRecord.new( + feature_class: FlipperFeature, gate_class: FlipperGate) + + Flipper.new(adapter) + end + end + end +end diff --git a/lib/github/import.rb b/lib/github/import.rb index 06beb607a3e..9c7eb965f93 100644 --- a/lib/github/import.rb +++ b/lib/github/import.rb @@ -1,4 +1,5 @@ require_relative 'error' + module Github class Import include Gitlab::ShellAdapter @@ -6,6 +7,7 @@ module Github class MergeRequest < ::MergeRequest self.table_name = 'merge_requests' + self.reset_callbacks :create self.reset_callbacks :save self.reset_callbacks :commit self.reset_callbacks :update @@ -16,6 +18,7 @@ module Github self.table_name = 'issues' self.reset_callbacks :save + self.reset_callbacks :create self.reset_callbacks :commit self.reset_callbacks :update self.reset_callbacks :validate @@ -79,7 +82,7 @@ module Github def fetch_repository begin project.create_repository unless project.repository.exists? - project.repository.add_remote('github', "https://{options.fetch(:token)}@github.com/#{repo}.git") + project.repository.add_remote('github', "https://#{options.fetch(:token)}@github.com/#{repo}.git") project.repository.set_remote_as_mirror('github') project.repository.fetch_remote('github', forced: true) rescue Gitlab::Shell::Error => e diff --git a/lib/gitlab/access.rb b/lib/gitlab/access.rb index 8c28009b9c6..4714ab18cc1 100644 --- a/lib/gitlab/access.rb +++ b/lib/gitlab/access.rb @@ -32,7 +32,7 @@ module Gitlab "Guest" => GUEST, "Reporter" => REPORTER, "Developer" => DEVELOPER, - "Master" => MASTER, + "Master" => MASTER } end @@ -47,7 +47,7 @@ module Gitlab guest: GUEST, reporter: REPORTER, developer: DEVELOPER, - master: MASTER, + master: MASTER } end @@ -60,7 +60,7 @@ module Gitlab "Not protected: Both developers and masters can push new commits, force push, or delete the branch." => PROTECTION_NONE, "Protected against pushes: Developers cannot push new commits, but are allowed to accept merge requests to the branch." => PROTECTION_DEV_CAN_MERGE, "Partially protected: Developers can push new commits, but cannot force push or delete the branch. Masters can do all of those." => PROTECTION_DEV_CAN_PUSH, - "Fully protected: Developers cannot push new commits, force push, or delete the branch. Only masters can do any of those." => PROTECTION_FULL, + "Fully protected: Developers cannot push new commits, force push, or delete the branch. Only masters can do any of those." => PROTECTION_FULL } end diff --git a/lib/gitlab/asciidoc.rb b/lib/gitlab/asciidoc.rb index 96d38f6daa0..188d7bf7d1d 100644 --- a/lib/gitlab/asciidoc.rb +++ b/lib/gitlab/asciidoc.rb @@ -20,21 +20,28 @@ module Gitlab backend: :gitlab_html5, attributes: DEFAULT_ADOC_ATTRS } +<<<<<<< HEAD context[:pipeline] = :markup +======= + context[:pipeline] = :ascii_doc +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 plantuml_setup html = ::Asciidoctor.convert(input, asciidoc_opts) html = Banzai.render(html, context) +<<<<<<< HEAD +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 html.html_safe end def self.plantuml_setup Asciidoctor::PlantUml.configure do |conf| - conf.url = ApplicationSetting.current.plantuml_url - conf.svg_enable = ApplicationSetting.current.plantuml_enabled - conf.png_enable = ApplicationSetting.current.plantuml_enabled + conf.url = current_application_settings.plantuml_url + conf.svg_enable = current_application_settings.plantuml_enabled + conf.png_enable = current_application_settings.plantuml_enabled conf.txt_enable = false end end @@ -47,13 +54,13 @@ module Gitlab def stem(node) return super unless node.style.to_sym == :latexmath - %(<pre#{id_attribute(node)} class="code math js-render-math #{node.role}" data-math-style="display"><code>#{node.content}</code></pre>) + %(<pre#{id_attribute(node)} data-math-style="display"><code>#{node.content}</code></pre>) end def inline_quoted(node) return super unless node.type.to_sym == :latexmath - %(<code#{id_attribute(node)} class="code math js-render-math #{node.role}" data-math-style="inline">#{node.text}</code>) + %(<code#{id_attribute(node)} data-math-style="inline">#{node.text}</code>) end private diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index ea918b23a63..da07ba2f2a3 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -2,6 +2,8 @@ module Gitlab module Auth MissingPersonalTokenError = Class.new(StandardError) + REGISTRY_SCOPES = [:read_registry].freeze + # Scopes used for GitLab API access API_SCOPES = [:api, :read_user].freeze @@ -11,8 +13,10 @@ module Gitlab # Default scopes for OAuth applications that don't define their own DEFAULT_SCOPES = [:api].freeze + AVAILABLE_SCOPES = (API_SCOPES + REGISTRY_SCOPES).freeze + # Other available scopes - OPTIONAL_SCOPES = (API_SCOPES + OPENID_SCOPES - DEFAULT_SCOPES).freeze + OPTIONAL_SCOPES = (AVAILABLE_SCOPES + OPENID_SCOPES - DEFAULT_SCOPES).freeze class << self def find_for_git_client(login, password, project:, ip:) @@ -26,8 +30,8 @@ module Gitlab build_access_token_check(login, password) || lfs_token_check(login, password) || oauth_access_token_check(login, password) || - user_with_password_for_git(login, password) || personal_access_token_check(password) || + user_with_password_for_git(login, password) || Gitlab::Auth::Result.new rate_limit!(ip, success: result.success?, login: login) @@ -37,6 +41,9 @@ module Gitlab end def find_with_user_password(login, password) + # Avoid resource intensive login checks if password is not provided + return unless password.present? + Gitlab::Auth::UniqueIpsLimiter.limit_user! do user = User.by_login(login) @@ -44,7 +51,7 @@ module Gitlab # LDAP users are only authenticated via LDAP if user.nil? || user.ldap_user? # Second chance - try LDAP authentication - return nil unless Gitlab::LDAP::Config.enabled? + return unless Gitlab::LDAP::Config.enabled? Gitlab::LDAP::Authentication.login(login, password) else @@ -106,6 +113,7 @@ module Gitlab def oauth_access_token_check(login, password) if login == "oauth2" && password.present? token = Doorkeeper::AccessToken.by_token(password) + if valid_oauth_token?(token) user = User.find_by(id: token.resource_owner_id) Gitlab::Auth::Result.new(user, nil, :oauth, full_authentication_abilities) @@ -118,17 +126,23 @@ module Gitlab token = PersonalAccessTokensFinder.new(state: 'active').find_by(token: password) - if token && valid_api_token?(token) - Gitlab::Auth::Result.new(token.user, nil, :personal_token, full_authentication_abilities) + if token && valid_scoped_token?(token, AVAILABLE_SCOPES.map(&:to_s)) + Gitlab::Auth::Result.new(token.user, nil, :personal_token, abilities_for_scope(token.scopes)) end end def valid_oauth_token?(token) - token && token.accessible? && valid_api_token?(token) + token && token.accessible? && valid_scoped_token?(token, ["api"]) end - def valid_api_token?(token) - AccessTokenValidationService.new(token).include_any_scope?(['api']) + def valid_scoped_token?(token, scopes) + AccessTokenValidationService.new(token).include_any_scope?(scopes) + end + + def abilities_for_scope(scopes) + scopes.map do |scope| + self.public_send(:"#{scope}_scope_authentication_abilities") + end.flatten.uniq end def lfs_token_check(login, password) @@ -199,6 +213,16 @@ module Gitlab :create_container_image ] end + alias_method :api_scope_authentication_abilities, :full_authentication_abilities + + def read_registry_scope_authentication_abilities + [:read_container_image] + end + + # The currently used auth method doesn't allow any actions for this scope + def read_user_scope_authentication_abilities + [] + end end end end diff --git a/lib/gitlab/auth/result.rb b/lib/gitlab/auth/result.rb index 39b86c61a18..75451cf8aa9 100644 --- a/lib/gitlab/auth/result.rb +++ b/lib/gitlab/auth/result.rb @@ -15,6 +15,10 @@ module Gitlab def success? actor.present? || type == :ci end + + def failed? + !success? + end end end end diff --git a/lib/gitlab/chat_commands/command.rb b/lib/gitlab/chat_commands/command.rb index f34ed0f4cf2..3e0c30c33b7 100644 --- a/lib/gitlab/chat_commands/command.rb +++ b/lib/gitlab/chat_commands/command.rb @@ -5,7 +5,7 @@ module Gitlab Gitlab::ChatCommands::IssueShow, Gitlab::ChatCommands::IssueNew, Gitlab::ChatCommands::IssueSearch, - Gitlab::ChatCommands::Deploy, + Gitlab::ChatCommands::Deploy ].freeze def execute diff --git a/lib/gitlab/chat_commands/presenters/base.rb b/lib/gitlab/chat_commands/presenters/base.rb index 2700a5a2ad5..05994bee79d 100644 --- a/lib/gitlab/chat_commands/presenters/base.rb +++ b/lib/gitlab/chat_commands/presenters/base.rb @@ -45,9 +45,9 @@ module Gitlab end def format_response(response) - response[:text] = format(response[:text]) if response.has_key?(:text) + response[:text] = format(response[:text]) if response.key?(:text) - if response.has_key?(:attachments) + if response.key?(:attachments) response[:attachments].each do |attachment| attachment[:pretext] = format(attachment[:pretext]) if attachment[:pretext] attachment[:text] = format(attachment[:text]) if attachment[:text] diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index 8793b20aa35..b6805230348 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -1,7 +1,20 @@ module Gitlab module Checks class ChangeAccess - # protocol is currently used only in EE + ERROR_MESSAGES = { + push_code: 'You are not allowed to push code to this project.', + delete_default_branch: 'The default branch of a project cannot be deleted.', + force_push_protected_branch: 'You are not allowed to force push code to a protected branch on this project.', + non_master_delete_protected_branch: 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.', + non_web_delete_protected_branch: 'You can only delete protected branches using the web interface.', + merge_protected_branch: 'You are not allowed to merge code into protected branches on this project.', + push_protected_branch: 'You are not allowed to push code to protected branches on this project.', + change_existing_tags: 'You are not allowed to change existing tags on this project.', + update_protected_tag: 'Protected tags cannot be updated.', + delete_protected_tag: 'Protected tags cannot be deleted.', + create_protected_tag: 'You are not allowed to create this tag as it is protected.' + }.freeze + attr_reader :user_access, :project, :skip_authorization, :protocol def initialize( @@ -18,74 +31,87 @@ module Gitlab end def exec - error = push_checks || tag_checks || protected_branch_checks + return true if skip_authorization - if error - GitAccessStatus.new(false, error) - else - GitAccessStatus.new(true) - end + push_checks + branch_checks + tag_checks + + true end protected - def protected_branch_checks - return if skip_authorization + def push_checks + if user_access.cannot_do_action?(:push_code) + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_code] + end + end + + def branch_checks return unless @branch_name + + if deletion? && @branch_name == project.default_branch + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_default_branch] + end + + protected_branch_checks + end + + def protected_branch_checks return unless ProtectedBranch.protected?(project, @branch_name) if forced_push? - return "You are not allowed to force push code to a protected branch on this project." - elsif deletion? - return "You are not allowed to delete protected branches from this project." + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:force_push_protected_branch] end + if deletion? + protected_branch_deletion_checks + else + protected_branch_push_checks + end + end + + def protected_branch_deletion_checks + unless user_access.can_delete_branch?(@branch_name) + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_master_delete_protected_branch] + end + + unless protocol == 'web' + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_web_delete_protected_branch] + end + end + + def protected_branch_push_checks if matching_merge_request? - if user_access.can_merge_to_branch?(@branch_name) || user_access.can_push_to_branch?(@branch_name) - return - else - "You are not allowed to merge code into protected branches on this project." + unless user_access.can_merge_to_branch?(@branch_name) || user_access.can_push_to_branch?(@branch_name) + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:merge_protected_branch] end else - if user_access.can_push_to_branch?(@branch_name) - return - else - "You are not allowed to push code to protected branches on this project." + unless user_access.can_push_to_branch?(@branch_name) + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_protected_branch] end end end def tag_checks - return if skip_authorization - return unless @tag_name if tag_exists? && user_access.cannot_do_action?(:admin_project) - return "You are not allowed to change existing tags on this project." + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:change_existing_tags] end protected_tag_checks end def protected_tag_checks - return unless tag_protected? - return "Protected tags cannot be updated." if update? - return "Protected tags cannot be deleted." if deletion? + return unless ProtectedTag.protected?(project, @tag_name) - unless user_access.can_create_tag?(@tag_name) - return "You are not allowed to create this tag as it is protected." - end - end - - def tag_protected? - ProtectedTag.protected?(project, @tag_name) - end + raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:update_protected_tag]) if update? + raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_protected_tag]) if deletion? - def push_checks - return if skip_authorization - - if user_access.cannot_do_action?(:push_code) - "You are not allowed to push code to this project." + unless user_access.can_create_tag?(@tag_name) + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:create_protected_tag] end end diff --git a/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb b/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb index 9b9a0a8125a..a78a85397bd 100644 --- a/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb +++ b/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb @@ -21,7 +21,13 @@ module Gitlab def validate_variables(variables) variables.is_a?(Hash) && - variables.all? { |key, value| validate_string(key) && validate_string(value) } + variables.flatten.all? do |value| + validate_string(value) || validate_integer(value) + end + end + + def validate_integer(value) + value.is_a?(Integer) end def validate_string(value) diff --git a/lib/gitlab/ci/config/entry/variables.rb b/lib/gitlab/ci/config/entry/variables.rb index c3b0e651c3a..8acab605c91 100644 --- a/lib/gitlab/ci/config/entry/variables.rb +++ b/lib/gitlab/ci/config/entry/variables.rb @@ -15,6 +15,10 @@ module Gitlab def self.default {} end + + def value + Hash[@config.map { |key, value| [key.to_s, value.to_s] }] + end end end end diff --git a/lib/gitlab/ci/cron_parser.rb b/lib/gitlab/ci/cron_parser.rb index dad8c3cdf5b..551483d0aaa 100644 --- a/lib/gitlab/ci/cron_parser.rb +++ b/lib/gitlab/ci/cron_parser.rb @@ -11,7 +11,7 @@ module Gitlab def next_time_from(time) @cron_line ||= try_parse_cron(@cron, @cron_timezone) - @cron_line.next_time(time).in_time_zone(Time.zone) if @cron_line.present? + @cron_line.next_time(time).utc.in_time_zone(Time.zone) if @cron_line.present? end def cron_valid? diff --git a/lib/gitlab/ci/stage/seed.rb b/lib/gitlab/ci/stage/seed.rb new file mode 100644 index 00000000000..f81f9347b4d --- /dev/null +++ b/lib/gitlab/ci/stage/seed.rb @@ -0,0 +1,49 @@ +module Gitlab + module Ci + module Stage + class Seed + attr_reader :pipeline + delegate :project, to: :pipeline + + def initialize(pipeline, stage, jobs) + @pipeline = pipeline + @stage = { name: stage } + @jobs = jobs.to_a.dup + end + + def user=(current_user) + @jobs.map! do |attributes| + attributes.merge(user: current_user) + end + end + + def stage + @stage.merge(project: project) + end + + def builds + trigger = pipeline.trigger_requests.first + + @jobs.map do |attributes| + attributes.merge(project: project, + ref: pipeline.ref, + tag: pipeline.tag, + trigger_request: trigger) + end + end + + def create! + pipeline.stages.create!(stage).tap do |stage| + builds_attributes = builds.map do |attributes| + attributes.merge(stage_id: stage.id) + end + + pipeline.builds.create!(builds_attributes).each do |build| + yield build if block_given? + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/status/build/cancelable.rb b/lib/gitlab/ci/status/build/cancelable.rb index 57b533bad99..439ef0ce015 100644 --- a/lib/gitlab/ci/status/build/cancelable.rb +++ b/lib/gitlab/ci/status/build/cancelable.rb @@ -12,7 +12,7 @@ module Gitlab end def action_path - cancel_namespace_project_build_path(subject.project.namespace, + cancel_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/common.rb b/lib/gitlab/ci/status/build/common.rb index 3fec2c5d4db..b173c23fba4 100644 --- a/lib/gitlab/ci/status/build/common.rb +++ b/lib/gitlab/ci/status/build/common.rb @@ -8,7 +8,7 @@ module Gitlab end def details_path - namespace_project_build_path(subject.project.namespace, + namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index c6139f1b716..e80f3263794 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -20,7 +20,7 @@ module Gitlab end def action_path - play_namespace_project_build_path(subject.project.namespace, + play_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/retryable.rb b/lib/gitlab/ci/status/build/retryable.rb index 505f80848b2..56303e4cb17 100644 --- a/lib/gitlab/ci/status/build/retryable.rb +++ b/lib/gitlab/ci/status/build/retryable.rb @@ -16,7 +16,7 @@ module Gitlab end def action_path - retry_namespace_project_build_path(subject.project.namespace, + retry_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/stop.rb b/lib/gitlab/ci/status/build/stop.rb index 0b5199e5483..2778d6f3b52 100644 --- a/lib/gitlab/ci/status/build/stop.rb +++ b/lib/gitlab/ci/status/build/stop.rb @@ -20,7 +20,7 @@ module Gitlab end def action_path - play_namespace_project_build_path(subject.project.namespace, + play_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/canceled.rb b/lib/gitlab/ci/status/canceled.rb index 97c121ce7b9..e5fdc1f8136 100644 --- a/lib/gitlab/ci/status/canceled.rb +++ b/lib/gitlab/ci/status/canceled.rb @@ -3,11 +3,11 @@ module Gitlab module Status class Canceled < Status::Core def text - 'canceled' + s_('CiStatusText|canceled') end def label - 'canceled' + s_('CiStatusLabel|canceled') end def icon diff --git a/lib/gitlab/ci/status/created.rb b/lib/gitlab/ci/status/created.rb index 0721bf6ec7c..d188bd286a6 100644 --- a/lib/gitlab/ci/status/created.rb +++ b/lib/gitlab/ci/status/created.rb @@ -3,11 +3,11 @@ module Gitlab module Status class Created < Status::Core def text - 'created' + s_('CiStatusText|created') end def label - 'created' + s_('CiStatusLabel|created') end def icon diff --git a/lib/gitlab/ci/status/failed.rb b/lib/gitlab/ci/status/failed.rb index cb75e9383a8..38e45714c22 100644 --- a/lib/gitlab/ci/status/failed.rb +++ b/lib/gitlab/ci/status/failed.rb @@ -3,11 +3,11 @@ module Gitlab module Status class Failed < Status::Core def text - 'failed' + s_('CiStatusText|failed') end def label - 'failed' + s_('CiStatusLabel|failed') end def icon diff --git a/lib/gitlab/ci/status/manual.rb b/lib/gitlab/ci/status/manual.rb index f8f6c2903ba..a4a7edadac9 100644 --- a/lib/gitlab/ci/status/manual.rb +++ b/lib/gitlab/ci/status/manual.rb @@ -3,11 +3,11 @@ module Gitlab module Status class Manual < Status::Core def text - 'manual' + s_('CiStatusText|manual') end def label - 'manual action' + s_('CiStatusLabel|manual action') end def icon diff --git a/lib/gitlab/ci/status/pending.rb b/lib/gitlab/ci/status/pending.rb index f40cc1314dc..5164260b861 100644 --- a/lib/gitlab/ci/status/pending.rb +++ b/lib/gitlab/ci/status/pending.rb @@ -3,11 +3,11 @@ module Gitlab module Status class Pending < Status::Core def text - 'pending' + s_('CiStatusText|pending') end def label - 'pending' + s_('CiStatusLabel|pending') end def icon diff --git a/lib/gitlab/ci/status/pipeline/blocked.rb b/lib/gitlab/ci/status/pipeline/blocked.rb index 37dfe43fb62..bf7e484ee9b 100644 --- a/lib/gitlab/ci/status/pipeline/blocked.rb +++ b/lib/gitlab/ci/status/pipeline/blocked.rb @@ -4,11 +4,11 @@ module Gitlab module Pipeline class Blocked < Status::Extended def text - 'blocked' + s_('CiStatusText|blocked') end def label - 'waiting for manual action' + s_('CiStatusLabel|waiting for manual action') end def self.matches?(pipeline, user) diff --git a/lib/gitlab/ci/status/running.rb b/lib/gitlab/ci/status/running.rb index 1237cd47dc8..993937e98ca 100644 --- a/lib/gitlab/ci/status/running.rb +++ b/lib/gitlab/ci/status/running.rb @@ -3,11 +3,11 @@ module Gitlab module Status class Running < Status::Core def text - 'running' + s_('CiStatus|running') end def label - 'running' + s_('CiStatus|running') end def icon diff --git a/lib/gitlab/ci/status/skipped.rb b/lib/gitlab/ci/status/skipped.rb index 28005d91503..0c942920b02 100644 --- a/lib/gitlab/ci/status/skipped.rb +++ b/lib/gitlab/ci/status/skipped.rb @@ -3,11 +3,11 @@ module Gitlab module Status class Skipped < Status::Core def text - 'skipped' + s_('CiStatusText|skipped') end def label - 'skipped' + s_('CiStatusLabel|skipped') end def icon diff --git a/lib/gitlab/ci/status/success.rb b/lib/gitlab/ci/status/success.rb index 88f7758a270..d7af98857b0 100644 --- a/lib/gitlab/ci/status/success.rb +++ b/lib/gitlab/ci/status/success.rb @@ -3,11 +3,11 @@ module Gitlab module Status class Success < Status::Core def text - 'passed' + s_('CiStatusText|passed') end def label - 'passed' + s_('CiStatusLabel|passed') end def icon diff --git a/lib/gitlab/ci/status/success_warning.rb b/lib/gitlab/ci/status/success_warning.rb index df6e76b0151..4d7d82e04cf 100644 --- a/lib/gitlab/ci/status/success_warning.rb +++ b/lib/gitlab/ci/status/success_warning.rb @@ -7,11 +7,11 @@ module Gitlab # class SuccessWarning < Status::Extended def text - 'passed' + s_('CiStatusText|passed') end def label - 'passed with warnings' + s_('CiStatusLabel|passed with warnings') end def icon diff --git a/lib/gitlab/ci/trace/stream.rb b/lib/gitlab/ci/trace/stream.rb index fa462cbe095..c4c0623df6c 100644 --- a/lib/gitlab/ci/trace/stream.rb +++ b/lib/gitlab/ci/trace/stream.rb @@ -73,7 +73,7 @@ module Gitlab match = "" - stream.each_line do |line| + reverse_line do |line| matches = line.scan(regex) next unless matches.is_a?(Array) next if matches.empty? @@ -86,34 +86,39 @@ module Gitlab nil rescue # if bad regex or something goes wrong we dont want to interrupt transition - # so we just silentrly ignore error for now + # so we just silently ignore error for now end private - def read_last_lines(last_lines) - chunks = [] - pos = lines = 0 - max = stream.size - - # We want an extra line to make sure fist line has full contents - while lines <= last_lines && pos < max - pos += BUFFER_SIZE - - buf = - if pos <= max - stream.seek(-pos, IO::SEEK_END) - stream.read(BUFFER_SIZE) - else # Reached the head, read only left - stream.seek(0) - stream.read(BUFFER_SIZE - (pos - max)) - end - - lines += buf.count("\n") - chunks.unshift(buf) + def read_last_lines(limit) + to_enum(:reverse_line).first(limit).reverse.join + end + + def reverse_line + stream.seek(0, IO::SEEK_END) + debris = '' + + until (buf = read_backward(BUFFER_SIZE)).empty? + buf += debris + debris, *lines = buf.each_line.to_a + lines.reverse_each do |line| + yield(line.force_encoding('UTF-8')) + end end - chunks.join.lines.last(last_lines).join + yield(debris.force_encoding('UTF-8')) unless debris.empty? + end + + def read_backward(length) + cur_offset = stream.tell + start = cur_offset - length + start = 0 if start < 0 + + stream.seek(start, IO::SEEK_SET) + stream.read(cur_offset - start).tap do + stream.seek(start, IO::SEEK_SET) + end end end end diff --git a/lib/gitlab/ci_access.rb b/lib/gitlab/ci_access.rb new file mode 100644 index 00000000000..def1373d8cf --- /dev/null +++ b/lib/gitlab/ci_access.rb @@ -0,0 +1,9 @@ +module Gitlab + # For backwards compatibility, generic CI (which is a build without a user) is + # allowed to :build_download_code without any other checks. + class CiAccess + def can_do_action?(action) + action == :build_download_code + end + end +end diff --git a/lib/gitlab/contributions_calendar.rb b/lib/gitlab/contributions_calendar.rb index 15992b77680..060e013183f 100644 --- a/lib/gitlab/contributions_calendar.rb +++ b/lib/gitlab/contributions_calendar.rb @@ -28,7 +28,7 @@ module Gitlab union = Gitlab::SQL::Union.new([repo_events, issue_events, mr_events, note_events]) events = Event.find_by_sql(union.to_sql).map(&:attributes) - @activity_events = events.each_with_object(Hash.new {|h, k| h[k] = 0 }) do |event, activities| + @activity_dates = events.each_with_object(Hash.new {|h, k| h[k] = 0 }) do |event, activities| activities[event["date"]] += event["total_amount"] end end diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb index 82576d197fe..48735fd197d 100644 --- a/lib/gitlab/current_settings.rb +++ b/lib/gitlab/current_settings.rb @@ -8,45 +8,62 @@ module Gitlab end end - def ensure_application_settings! - return fake_application_settings unless connect_to_db? + delegate :sidekiq_throttling_enabled?, to: :current_application_settings - unless ENV['IN_MEMORY_APPLICATION_SETTINGS'] == 'true' - begin - settings = ::ApplicationSetting.current - # In case Redis isn't running or the Redis UNIX socket file is not available - rescue ::Redis::BaseError, ::Errno::ENOENT - settings = ::ApplicationSetting.last - end + def fake_application_settings + OpenStruct.new(::ApplicationSetting.defaults) + end - settings ||= ::ApplicationSetting.create_from_defaults unless ActiveRecord::Migrator.needs_migration? + private + + def ensure_application_settings! + unless ENV['IN_MEMORY_APPLICATION_SETTINGS'] == 'true' + settings = retrieve_settings_from_database? end settings || in_memory_application_settings end - delegate :sidekiq_throttling_enabled?, to: :current_application_settings + def retrieve_settings_from_database? + settings = retrieve_settings_from_database_cache? + return settings if settings.present? + + return fake_application_settings unless connect_to_db? + + begin + db_settings = ::ApplicationSetting.current + # In case Redis isn't running or the Redis UNIX socket file is not available + rescue ::Redis::BaseError, ::Errno::ENOENT + db_settings = ::ApplicationSetting.last + end + db_settings || ::ApplicationSetting.create_from_defaults + end + + def retrieve_settings_from_database_cache? + begin + settings = ApplicationSetting.cached + rescue ::Redis::BaseError, ::Errno::ENOENT + # In case Redis isn't running or the Redis UNIX socket file is not available + settings = nil + end + settings + end def in_memory_application_settings @in_memory_application_settings ||= ::ApplicationSetting.new(::ApplicationSetting.defaults) - # In case migrations the application_settings table is not created yet, - # we fallback to a simple OpenStruct rescue ActiveRecord::StatementInvalid, ActiveRecord::UnknownAttributeError + # In case migrations the application_settings table is not created yet, + # we fallback to a simple OpenStruct fake_application_settings end - def fake_application_settings - OpenStruct.new(::ApplicationSetting.defaults) - end - - private - def connect_to_db? # When the DBMS is not available, an exception (e.g. PG::ConnectionBad) is raised active_db_connection = ActiveRecord::Base.connection.active? rescue false active_db_connection && - ActiveRecord::Base.connection.table_exists?('application_settings') + ActiveRecord::Base.connection.table_exists?('application_settings') && + !ActiveRecord::Migrator.needs_migration? rescue ActiveRecord::NoDatabaseError false end diff --git a/lib/gitlab/cycle_analytics/permissions.rb b/lib/gitlab/cycle_analytics/permissions.rb index bef3b95ff1b..1e11e84a9cb 100644 --- a/lib/gitlab/cycle_analytics/permissions.rb +++ b/lib/gitlab/cycle_analytics/permissions.rb @@ -7,7 +7,7 @@ module Gitlab test: :read_build, review: :read_merge_request, staging: :read_build, - production: :read_issue, + production: :read_issue }.freeze def self.get(*args) diff --git a/lib/gitlab/data_builder/build.rb b/lib/gitlab/data_builder/build.rb index f78106f5b10..8e74e18a311 100644 --- a/lib/gitlab/data_builder/build.rb +++ b/lib/gitlab/data_builder/build.rb @@ -36,7 +36,7 @@ module Gitlab user: { id: user.try(:id), name: user.try(:name), - email: user.try(:email), + email: user.try(:email) }, commit: { @@ -49,7 +49,7 @@ module Gitlab status: commit.status, duration: commit.duration, started_at: commit.started_at, - finished_at: commit.finished_at, + finished_at: commit.finished_at }, repository: { @@ -60,7 +60,7 @@ module Gitlab git_http_url: project.http_url_to_repo, git_ssh_url: project.ssh_url_to_repo, visibility_level: project.visibility_level - }, + } } data diff --git a/lib/gitlab/data_builder/pipeline.rb b/lib/gitlab/data_builder/pipeline.rb index 182a30fd74d..e47fb85b5ee 100644 --- a/lib/gitlab/data_builder/pipeline.rb +++ b/lib/gitlab/data_builder/pipeline.rb @@ -22,7 +22,7 @@ module Gitlab sha: pipeline.sha, before_sha: pipeline.before_sha, status: pipeline.status, - stages: pipeline.stages_name, + stages: pipeline.stages_names, created_at: pipeline.created_at, finished_at: pipeline.finished_at, duration: pipeline.duration diff --git a/lib/gitlab/data_builder/push.rb b/lib/gitlab/data_builder/push.rb index 1ff34553f0a..e81d19a7a2e 100644 --- a/lib/gitlab/data_builder/push.rb +++ b/lib/gitlab/data_builder/push.rb @@ -11,6 +11,7 @@ module Gitlab # ref: String, # user_id: String, # user_name: String, + # user_username: String, # user_email: String # project_id: String, # repository: { @@ -51,6 +52,7 @@ module Gitlab message: message, user_id: user.id, user_name: user.name, + user_username: user.username, user_email: user.email, user_avatar: user.avatar_url, project_id: project.id, diff --git a/lib/gitlab/data_builder/repository.rb b/lib/gitlab/data_builder/repository.rb new file mode 100644 index 00000000000..b42dc052949 --- /dev/null +++ b/lib/gitlab/data_builder/repository.rb @@ -0,0 +1,35 @@ +module Gitlab + module DataBuilder + module Repository + extend self + + # Produce a hash of post-receive data + def update(project, user, changes, refs) + { + event_name: 'repository_update', + + user_id: user.id, + user_name: user.name, + user_email: user.email, + user_avatar: user.avatar_url, + + project_id: project.id, + project: project.hook_attrs, + + changes: changes, + + refs: refs + } + end + + # Produce a hash of partial data for a single change + def single_change(oldrev, newrev, ref) + { + before: oldrev, + after: newrev, + ref: ref + } + end + end + end +end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index e76c9abbe04..a412bb6dbd2 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -42,7 +42,7 @@ module Gitlab 'in the body of your migration class' end - if Database.postgresql? + if supports_drop_index_concurrently? options = options.merge({ algorithm: :concurrently }) disable_statement_timeout end @@ -50,6 +50,39 @@ module Gitlab remove_index(table_name, options.merge({ column: column_name })) end + # Removes an existing index, concurrently when supported + # + # On PostgreSQL this method removes an index concurrently. + # + # Example: + # + # remove_concurrent_index :users, "index_X_by_Y" + # + # See Rails' `remove_index` for more info on the available arguments. + def remove_concurrent_index_by_name(table_name, index_name, options = {}) + if transaction_open? + raise 'remove_concurrent_index_by_name can not be run inside a transaction, ' \ + 'you can disable transactions by calling disable_ddl_transaction! ' \ + 'in the body of your migration class' + end + + if supports_drop_index_concurrently? + options = options.merge({ algorithm: :concurrently }) + disable_statement_timeout + end + + remove_index(table_name, options.merge({ name: index_name })) + end + + # Only available on Postgresql >= 9.2 + def supports_drop_index_concurrently? + return false unless Database.postgresql? + + version = select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i + + version >= 90200 + end + # Adds a foreign key with only minimal locking on the tables involved. # # This method only requires minimal locking when using PostgreSQL. When diff --git a/lib/gitlab/dependency_linker.rb b/lib/gitlab/dependency_linker.rb new file mode 100644 index 00000000000..3192bf6f667 --- /dev/null +++ b/lib/gitlab/dependency_linker.rb @@ -0,0 +1,27 @@ +module Gitlab + module DependencyLinker + LINKERS = [ + GemfileLinker, + GemspecLinker, + PackageJsonLinker, + ComposerJsonLinker, + PodfileLinker, + PodspecLinker, + PodspecJsonLinker, + CartfileLinker, + GodepsJsonLinker, + RequirementsTxtLinker + ].freeze + + def self.linker(blob_name) + LINKERS.find { |linker| linker.support?(blob_name) } + end + + def self.link(blob_name, plain_text, highlighted_text) + linker = linker(blob_name) + return highlighted_text unless linker + + linker.link(plain_text, highlighted_text) + end + end +end diff --git a/lib/gitlab/dependency_linker/base_linker.rb b/lib/gitlab/dependency_linker/base_linker.rb new file mode 100644 index 00000000000..7bbd154eb03 --- /dev/null +++ b/lib/gitlab/dependency_linker/base_linker.rb @@ -0,0 +1,86 @@ +module Gitlab + module DependencyLinker + class BaseLinker + URL_REGEX = %r{https?://[^'" ]+}.freeze + REPO_REGEX = %r{[^/'" ]+/[^/'" ]+}.freeze + + class_attribute :file_type + + def self.support?(blob_name) + Gitlab::FileDetector.type_of(blob_name) == file_type + end + + def self.link(*args) + new(*args).link + end + + attr_accessor :plain_text, :highlighted_text + + def initialize(plain_text, highlighted_text) + @plain_text = plain_text + @highlighted_text = highlighted_text + end + + def link + link_dependencies + + highlighted_lines.join.html_safe + end + + private + + def link_dependencies + raise NotImplementedError + end + + def license_url(name) + Licensee::License.find(name)&.url + end + + def github_url(name) + "https://github.com/#{name}" + end + + def link_tag(name, url) + %{<a href="#{ERB::Util.html_escape_once(url)}" rel="nofollow noreferrer noopener" target="_blank">#{ERB::Util.html_escape_once(name)}</a>} + end + + # Links package names based on regex. + # + # Example: + # link_regex(/(github:|:github =>)\s*['"](?<name>[^'"]+)['"]/) + # # Will link `user/repo` in `github: "user/repo"` or `:github => "user/repo"` + def link_regex(regex, &url_proc) + highlighted_lines.map!.with_index do |rich_line, i| + marker = StringRegexMarker.new(plain_lines[i], rich_line.html_safe) + + marker.mark(regex, group: :name) do |text, left:, right:| + url = yield(text) + url ? link_tag(text, url) : text + end + end + end + + def plain_lines + @plain_lines ||= plain_text.lines + end + + def highlighted_lines + @highlighted_lines ||= highlighted_text.lines + end + + def regexp_for_value(value, default: /[^'" ]+/) + case value + when Array + Regexp.union(value.map { |v| regexp_for_value(v, default: default) }) + when String + Regexp.escape(value) + when Regexp + value + else + default + end + end + end + end +end diff --git a/lib/gitlab/dependency_linker/cartfile_linker.rb b/lib/gitlab/dependency_linker/cartfile_linker.rb new file mode 100644 index 00000000000..4f69f2c4ab2 --- /dev/null +++ b/lib/gitlab/dependency_linker/cartfile_linker.rb @@ -0,0 +1,14 @@ +module Gitlab + module DependencyLinker + class CartfileLinker < MethodLinker + self.file_type = :cartfile + + private + + def link_dependencies + link_method_call('github', REPO_REGEX, &method(:github_url)) + link_method_call(%w[github git binary], URL_REGEX, &:itself) + end + end + end +end diff --git a/lib/gitlab/dependency_linker/cocoapods.rb b/lib/gitlab/dependency_linker/cocoapods.rb new file mode 100644 index 00000000000..2fbde7da1b4 --- /dev/null +++ b/lib/gitlab/dependency_linker/cocoapods.rb @@ -0,0 +1,10 @@ +module Gitlab + module DependencyLinker + module Cocoapods + def package_url(name) + package = name.split("/", 2).first + "https://cocoapods.org/pods/#{package}" + end + end + end +end diff --git a/lib/gitlab/dependency_linker/composer_json_linker.rb b/lib/gitlab/dependency_linker/composer_json_linker.rb new file mode 100644 index 00000000000..0245bf4077a --- /dev/null +++ b/lib/gitlab/dependency_linker/composer_json_linker.rb @@ -0,0 +1,18 @@ +module Gitlab + module DependencyLinker + class ComposerJsonLinker < PackageJsonLinker + self.file_type = :composer_json + + private + + def link_packages + link_packages_at_key("require", &method(:package_url)) + link_packages_at_key("require-dev", &method(:package_url)) + end + + def package_url(name) + "https://packagist.org/packages/#{name}" if name =~ %r{\A#{REPO_REGEX}\z} + end + end + end +end diff --git a/lib/gitlab/dependency_linker/gemfile_linker.rb b/lib/gitlab/dependency_linker/gemfile_linker.rb new file mode 100644 index 00000000000..d034ea67387 --- /dev/null +++ b/lib/gitlab/dependency_linker/gemfile_linker.rb @@ -0,0 +1,32 @@ +module Gitlab + module DependencyLinker + class GemfileLinker < MethodLinker + self.file_type = :gemfile + + private + + def link_dependencies + link_urls + link_packages + end + + def link_urls + # Link `github: "user/repo"` to https://github.com/user/repo + link_regex(/(github:|:github\s*=>)\s*['"](?<name>[^'"]+)['"]/, &method(:github_url)) + + # Link `git: "https://gitlab.example.com/user/repo"` to https://gitlab.example.com/user/repo + link_regex(%r{(git:|:git\s*=>)\s*['"](?<name>#{URL_REGEX})['"]}, &:itself) + + # Link `source "https://rubygems.org"` to https://rubygems.org + link_method_call('source', URL_REGEX, &:itself) + end + + def link_packages + # Link `gem "package_name"` to https://rubygems.org/gems/package_name + link_method_call('gem') do |name| + "https://rubygems.org/gems/#{name}" + end + end + end + end +end diff --git a/lib/gitlab/dependency_linker/gemspec_linker.rb b/lib/gitlab/dependency_linker/gemspec_linker.rb new file mode 100644 index 00000000000..f1783ee2ab4 --- /dev/null +++ b/lib/gitlab/dependency_linker/gemspec_linker.rb @@ -0,0 +1,18 @@ +module Gitlab + module DependencyLinker + class GemspecLinker < MethodLinker + self.file_type = :gemspec + + private + + def link_dependencies + link_method_call('homepage', URL_REGEX, &:itself) + link_method_call('license', &method(:license_url)) + + link_method_call(%w[name add_dependency add_runtime_dependency add_development_dependency]) do |name| + "https://rubygems.org/gems/#{name}" + end + end + end + end +end diff --git a/lib/gitlab/dependency_linker/godeps_json_linker.rb b/lib/gitlab/dependency_linker/godeps_json_linker.rb new file mode 100644 index 00000000000..fe091baee6d --- /dev/null +++ b/lib/gitlab/dependency_linker/godeps_json_linker.rb @@ -0,0 +1,26 @@ +module Gitlab + module DependencyLinker + class GodepsJsonLinker < JsonLinker + NESTED_REPO_REGEX = %r{([^/]+/)+[^/]+?}.freeze + + self.file_type = :godeps_json + + private + + def link_dependencies + link_json('ImportPath') do |path| + case path + when %r{\A(?<repo>gitlab\.com/#{NESTED_REPO_REGEX})\.git/(?<path>.+)\z}, + %r{\A(?<repo>git(lab|hub)\.com/#{REPO_REGEX})/(?<path>.+)\z} + + "https://#{$~[:repo]}/tree/master/#{$~[:path]}" + when /\Agolang\.org/ + "https://godoc.org/#{path}" + else + "https://#{path}" + end + end + end + end + end +end diff --git a/lib/gitlab/dependency_linker/json_linker.rb b/lib/gitlab/dependency_linker/json_linker.rb new file mode 100644 index 00000000000..a8ef25233d8 --- /dev/null +++ b/lib/gitlab/dependency_linker/json_linker.rb @@ -0,0 +1,44 @@ +module Gitlab + module DependencyLinker + class JsonLinker < BaseLinker + def link + return highlighted_text unless json + + super + end + + private + + # Links package names in a JSON key or values. + # + # Example: + # link_json('name') + # # Will link `package` in `"name": "package"` + # + # link_json('name', 'specific_package') + # # Will link `specific_package` in `"name": "specific_package"` + # + # link_json('name', /[^\/]+\/[^\/]+/) + # # Will link `user/repo` in `"name": "user/repo"`, but not `"name": "package"` + # + # link_json('specific_package', '1.0.1', link: :key) + # # Will link `specific_package` in `"specific_package": "1.0.1"` + def link_json(key, value = nil, link: :value, &url_proc) + key = regexp_for_value(key, default: /[^" ]+/) + value = regexp_for_value(value, default: /[^" ]+/) + + if link == :value + value = /(?<name>#{value})/ + else + key = /(?<name>#{key})/ + end + + link_regex(/"#{key}":\s*"#{value}"/, &url_proc) + end + + def json + @json ||= JSON.parse(plain_text) rescue nil + end + end + end +end diff --git a/lib/gitlab/dependency_linker/method_linker.rb b/lib/gitlab/dependency_linker/method_linker.rb new file mode 100644 index 00000000000..0ffa2a83c93 --- /dev/null +++ b/lib/gitlab/dependency_linker/method_linker.rb @@ -0,0 +1,39 @@ +module Gitlab + module DependencyLinker + class MethodLinker < BaseLinker + private + + # Links package names in a method call or assignment string argument. + # + # Example: + # link_method_call('gem') + # # Will link `package` in `gem "package"`, `gem("package")` and `gem = "package"` + # + # link_method_call('gem', 'specific_package') + # # Will link `specific_package` in `gem "specific_package"` + # + # link_method_call('github', /[^\/"]+\/[^\/"]+/) + # # Will link `user/repo` in `github "user/repo"`, but not `github "package"` + # + # link_method_call(%w[add_dependency add_development_dependency]) + # # Will link `spec.add_dependency "package"` and `spec.add_development_dependency "package"` + # + # link_method_call('name') + # # Will link `package` in `self.name = "package"` + def link_method_call(method_name, value = nil, &url_proc) + method_name = regexp_for_value(method_name) + value = regexp_for_value(value) + + regex = %r{ + #{method_name} # Method name + \s* # Whitespace + [(=]? # Opening brace or equals sign + \s* # Whitespace + ['"](?<name>#{value})['"] # Package name in quotes + }x + + link_regex(regex, &url_proc) + end + end + end +end diff --git a/lib/gitlab/dependency_linker/package_json_linker.rb b/lib/gitlab/dependency_linker/package_json_linker.rb new file mode 100644 index 00000000000..330c95f0880 --- /dev/null +++ b/lib/gitlab/dependency_linker/package_json_linker.rb @@ -0,0 +1,44 @@ +module Gitlab + module DependencyLinker + class PackageJsonLinker < JsonLinker + self.file_type = :package_json + + private + + def link_dependencies + link_json('name', json["name"], &method(:package_url)) + link_json('license', &method(:license_url)) + link_json(%w[homepage url], URL_REGEX, &:itself) + + link_packages + end + + def link_packages + link_packages_at_key("dependencies", &method(:package_url)) + link_packages_at_key("devDependencies", &method(:package_url)) + end + + def link_packages_at_key(key, &url_proc) + dependencies = json[key] + return unless dependencies + + dependencies.each do |name, version| + link_json(name, version, link: :key, &url_proc) + + link_json(name) do |value| + case value + when /\A#{URL_REGEX}\z/ + value + when /\A#{REPO_REGEX}\z/ + github_url(value) + end + end + end + end + + def package_url(name) + "https://npmjs.com/package/#{name}" + end + end + end +end diff --git a/lib/gitlab/dependency_linker/podfile_linker.rb b/lib/gitlab/dependency_linker/podfile_linker.rb new file mode 100644 index 00000000000..60ad166ea17 --- /dev/null +++ b/lib/gitlab/dependency_linker/podfile_linker.rb @@ -0,0 +1,15 @@ +module Gitlab + module DependencyLinker + class PodfileLinker < GemfileLinker + include Cocoapods + + self.file_type = :podfile + + private + + def link_packages + link_method_call('pod', &method(:package_url)) + end + end + end +end diff --git a/lib/gitlab/dependency_linker/podspec_json_linker.rb b/lib/gitlab/dependency_linker/podspec_json_linker.rb new file mode 100644 index 00000000000..d82237ed3f1 --- /dev/null +++ b/lib/gitlab/dependency_linker/podspec_json_linker.rb @@ -0,0 +1,32 @@ +module Gitlab + module DependencyLinker + class PodspecJsonLinker < JsonLinker + include Cocoapods + + self.file_type = :podspec_json + + private + + def link_dependencies + link_json('name', json["name"], &method(:package_url)) + link_json('license', &method(:license_url)) + link_json(%w[homepage git], URL_REGEX, &:itself) + + link_packages_at_key("dependencies", &method(:package_url)) + + json["subspecs"]&.each do |subspec| + link_packages_at_key("dependencies", subspec, &method(:package_url)) + end + end + + def link_packages_at_key(key, root = json, &url_proc) + dependencies = root[key] + return unless dependencies + + dependencies.each do |name, _| + link_regex(/"(?<name>#{Regexp.escape(name)})":\s*\[/, &url_proc) + end + end + end + end +end diff --git a/lib/gitlab/dependency_linker/podspec_linker.rb b/lib/gitlab/dependency_linker/podspec_linker.rb new file mode 100644 index 00000000000..a52c7a02439 --- /dev/null +++ b/lib/gitlab/dependency_linker/podspec_linker.rb @@ -0,0 +1,24 @@ +module Gitlab + module DependencyLinker + class PodspecLinker < MethodLinker + include Cocoapods + + STRING_REGEX = /['"](?<name>[^'"]+)['"]/.freeze + + self.file_type = :podspec + + private + + def link_dependencies + link_method_call('homepage', URL_REGEX, &:itself) + + link_regex(%r{(git:|:git\s*=>)\s*['"](?<name>#{URL_REGEX})['"]}, &:itself) + + link_method_call('license', &method(:license_url)) + link_regex(/license\s*=\s*\{\s*(type:|:type\s*=>)\s*#{STRING_REGEX}/, &method(:license_url)) + + link_method_call(%w[name dependency], &method(:package_url)) + end + end + end +end diff --git a/lib/gitlab/dependency_linker/requirements_txt_linker.rb b/lib/gitlab/dependency_linker/requirements_txt_linker.rb new file mode 100644 index 00000000000..2e197e5cd94 --- /dev/null +++ b/lib/gitlab/dependency_linker/requirements_txt_linker.rb @@ -0,0 +1,17 @@ +module Gitlab + module DependencyLinker + class RequirementsTxtLinker < BaseLinker + self.file_type = :requirements_txt + + private + + def link_dependencies + link_regex(/^(?<name>(?![a-z+]+:)[^#.-][^ ><=;\[]+)/) do |name| + "https://pypi.python.org/pypi/#{name}" + end + + link_regex(%r{^(?<name>https?://[^ ]+)}, &:itself) + end + end + end +end diff --git a/lib/gitlab/diff/diff_refs.rb b/lib/gitlab/diff/diff_refs.rb index 7948782aecc..371cbe04b9b 100644 --- a/lib/gitlab/diff/diff_refs.rb +++ b/lib/gitlab/diff/diff_refs.rb @@ -37,6 +37,16 @@ module Gitlab def complete? start_sha && head_sha end + + def compare_in(project) + # We're at the initial commit, so just get that as we can't compare to anything. + if Gitlab::Git.blank_ref?(start_sha) + project.commit(head_sha) + else + straight = start_sha == base_sha + CompareService.new(project, head_sha).execute(project, start_sha, straight: straight) + end + end end end end diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb index c6bf25b5874..2aef7fdaa35 100644 --- a/lib/gitlab/diff/file.rb +++ b/lib/gitlab/diff/file.rb @@ -1,16 +1,17 @@ module Gitlab module Diff class File - attr_reader :diff, :repository, :diff_refs + attr_reader :diff, :repository, :diff_refs, :fallback_diff_refs - delegate :new_file, :deleted_file, :renamed_file, - :old_path, :new_path, :a_mode, :b_mode, + delegate :new_file?, :deleted_file?, :renamed_file?, + :old_path, :new_path, :a_mode, :b_mode, :mode_changed?, :submodule?, :too_large?, :collapsed?, to: :diff, prefix: false - def initialize(diff, repository:, diff_refs: nil) + def initialize(diff, repository:, diff_refs: nil, fallback_diff_refs: nil) @diff = diff @repository = repository @diff_refs = diff_refs + @fallback_diff_refs = fallback_diff_refs end def position(line) @@ -49,24 +50,60 @@ module Gitlab line_code(line) if line end + def old_sha + diff_refs&.base_sha + end + + def new_sha + diff_refs&.head_sha + end + + def content_sha + return old_content_sha if deleted_file? + return @content_sha if defined?(@content_sha) + + refs = diff_refs || fallback_diff_refs + @content_sha = refs&.head_sha + end + def content_commit - return unless diff_refs + return @content_commit if defined?(@content_commit) + + sha = content_sha + @content_commit = repository.commit(sha) if sha + end + + def old_content_sha + return if new_file? + return @old_content_sha if defined?(@old_content_sha) - repository.commit(deleted_file ? old_ref : new_ref) + refs = diff_refs || fallback_diff_refs + @old_content_sha = refs&.base_sha end def old_content_commit - return unless diff_refs + return @old_content_commit if defined?(@old_content_commit) - repository.commit(old_ref) + sha = old_content_sha + @old_content_commit = repository.commit(sha) if sha end - def old_ref - diff_refs.try(:base_sha) + def blob + return @blob if defined?(@blob) + + sha = content_sha + return @blob = nil unless sha + + repository.blob_at(sha, file_path) end - def new_ref - diff_refs.try(:head_sha) + def old_blob + return @old_blob if defined?(@old_blob) + + sha = old_content_sha + return @old_blob = nil unless sha + + @old_blob = repository.blob_at(sha, old_path) end attr_writer :highlighted_diff_lines @@ -85,10 +122,6 @@ module Gitlab @parallel_diff_lines ||= Gitlab::Diff::ParallelDiff.new(self).parallelize end - def mode_changed? - a_mode && b_mode && a_mode != b_mode - end - def raw_diff diff.diff.to_s end @@ -117,20 +150,8 @@ module Gitlab diff_lines.count(&:removed?) end - def old_blob(commit = old_content_commit) - return unless commit - - repository.blob_at(commit.id, old_path) - end - - def blob(commit = content_commit) - return unless commit - - repository.blob_at(commit.id, file_path) - end - def file_identifier - "#{file_path}-#{new_file}-#{deleted_file}-#{renamed_file}" + "#{file_path}-#{new_file?}-#{deleted_file?}-#{renamed_file?}" end end end diff --git a/lib/gitlab/diff/file_collection/base.rb b/lib/gitlab/diff/file_collection/base.rb index 2b9fc65b985..a6007ebf531 100644 --- a/lib/gitlab/diff/file_collection/base.rb +++ b/lib/gitlab/diff/file_collection/base.rb @@ -2,32 +2,41 @@ module Gitlab module Diff module FileCollection class Base - attr_reader :project, :diff_options, :diff_view, :diff_refs + attr_reader :project, :diff_options, :diff_refs, :fallback_diff_refs delegate :count, :size, :real_size, to: :diff_files def self.default_options - ::Commit.max_diff_options.merge(ignore_whitespace_change: false, no_collapse: false) + ::Commit.max_diff_options.merge(ignore_whitespace_change: false, expanded: false) end - def initialize(diffable, project:, diff_options: nil, diff_refs: nil) + def initialize(diffable, project:, diff_options: nil, diff_refs: nil, fallback_diff_refs: nil) diff_options = self.class.default_options.merge(diff_options || {}) - @diffable = diffable - @diffs = diffable.raw_diffs(diff_options) - @project = project + @diffable = diffable + @diffs = diffable.raw_diffs(diff_options) + @project = project @diff_options = diff_options - @diff_refs = diff_refs + @diff_refs = diff_refs + @fallback_diff_refs = fallback_diff_refs end def diff_files @diff_files ||= @diffs.decorate! { |diff| decorate_diff!(diff) } end + def diff_file_with_old_path(old_path) + diff_files.find { |diff_file| diff_file.old_path == old_path } + end + + def diff_file_with_new_path(new_path) + diff_files.find { |diff_file| diff_file.new_path == new_path } + end + private def decorate_diff!(diff) - Gitlab::Diff::File.new(diff, repository: project.repository, diff_refs: diff_refs) + Gitlab::Diff::File.new(diff, repository: project.repository, diff_refs: diff_refs, fallback_diff_refs: fallback_diff_refs) end end end diff --git a/lib/gitlab/diff/file_collection/merge_request_diff.rb b/lib/gitlab/diff/file_collection/merge_request_diff.rb index 0bd226ef050..9a58b500a2c 100644 --- a/lib/gitlab/diff/file_collection/merge_request_diff.rb +++ b/lib/gitlab/diff/file_collection/merge_request_diff.rb @@ -8,7 +8,8 @@ module Gitlab super(merge_request_diff, project: merge_request_diff.project, diff_options: diff_options, - diff_refs: merge_request_diff.diff_refs) + diff_refs: merge_request_diff.diff_refs, + fallback_diff_refs: merge_request_diff.fallback_diff_refs) end def diff_files diff --git a/lib/gitlab/diff/highlight.rb b/lib/gitlab/diff/highlight.rb index 7db896522a9..ed2f541977a 100644 --- a/lib/gitlab/diff/highlight.rb +++ b/lib/gitlab/diff/highlight.rb @@ -3,7 +3,7 @@ module Gitlab class Highlight attr_reader :diff_file, :diff_lines, :raw_lines, :repository - delegate :old_path, :new_path, :old_ref, :new_ref, to: :diff_file, prefix: :diff + delegate :old_path, :new_path, :old_sha, :new_sha, to: :diff_file, prefix: :diff def initialize(diff_lines, repository: nil) @repository = repository @@ -61,12 +61,12 @@ module Gitlab def old_lines return unless diff_file - @old_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_old_ref, diff_old_path) + @old_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_old_sha, diff_old_path) end def new_lines return unless diff_file - @new_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_new_ref, diff_new_path) + @new_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_new_sha, diff_new_path) end end end diff --git a/lib/gitlab/diff/inline_diff_markdown_marker.rb b/lib/gitlab/diff/inline_diff_markdown_marker.rb new file mode 100644 index 00000000000..c2a2eb15931 --- /dev/null +++ b/lib/gitlab/diff/inline_diff_markdown_marker.rb @@ -0,0 +1,17 @@ +module Gitlab + module Diff + class InlineDiffMarkdownMarker < Gitlab::StringRangeMarker + MARKDOWN_SYMBOLS = { + addition: "+", + deletion: "-" + }.freeze + + def mark(line_inline_diffs, mode: nil) + super(line_inline_diffs) do |text, left:, right:| + symbol = MARKDOWN_SYMBOLS[mode] + "{#{symbol}#{text}#{symbol}}" + end + end + end + end +end diff --git a/lib/gitlab/diff/inline_diff_marker.rb b/lib/gitlab/diff/inline_diff_marker.rb index 736933b1c4b..919965100ae 100644 --- a/lib/gitlab/diff/inline_diff_marker.rb +++ b/lib/gitlab/diff/inline_diff_marker.rb @@ -1,137 +1,21 @@ module Gitlab module Diff - class InlineDiffMarker - MARKDOWN_SYMBOLS = { - addition: "+", - deletion: "-" - }.freeze - - attr_accessor :raw_line, :rich_line - - def initialize(raw_line, rich_line = raw_line) - @raw_line = raw_line - @rich_line = ERB::Util.html_escape(rich_line) - end - - def mark(line_inline_diffs, mode: nil, markdown: false) - return rich_line unless line_inline_diffs - - marker_ranges = [] - line_inline_diffs.each do |inline_diff_range| - # Map the inline-diff range based on the raw line to character positions in the rich line - inline_diff_positions = position_mapping[inline_diff_range].flatten - # Turn the array of character positions into ranges - marker_ranges.concat(collapse_ranges(inline_diff_positions)) - end - - offset = 0 - - # Mark each range - marker_ranges.each_with_index do |range, index| - before_content = - if markdown - "{#{MARKDOWN_SYMBOLS[mode]}" - else - "<span class='#{html_class_names(marker_ranges, mode, index)}'>" - end - after_content = - if markdown - "#{MARKDOWN_SYMBOLS[mode]}}" - else - "</span>" - end - offset = insert_around_range(rich_line, range, before_content, after_content, offset) + class InlineDiffMarker < Gitlab::StringRangeMarker + def mark(line_inline_diffs, mode: nil) + super(line_inline_diffs) do |text, left:, right:| + %{<span class="#{html_class_names(left, right, mode)}">#{text}</span>} end - - rich_line.html_safe end private - def html_class_names(marker_ranges, mode, index) + def html_class_names(left, right, mode) class_names = ["idiff"] - class_names << "left" if index == 0 - class_names << "right" if index == marker_ranges.length - 1 + class_names << "left" if left + class_names << "right" if right class_names << mode if mode class_names.join(" ") end - - # Mapping of character positions in the raw line, to the rich (highlighted) line - def position_mapping - @position_mapping ||= begin - mapping = [] - rich_pos = 0 - (0..raw_line.length).each do |raw_pos| - rich_char = rich_line[rich_pos] - - # The raw and rich lines are the same except for HTML tags, - # so skip over any `<...>` segment - while rich_char == '<' - until rich_char == '>' - rich_pos += 1 - rich_char = rich_line[rich_pos] - end - - rich_pos += 1 - rich_char = rich_line[rich_pos] - end - - # multi-char HTML entities in the rich line correspond to a single character in the raw line - if rich_char == '&' - multichar_mapping = [rich_pos] - until rich_char == ';' - rich_pos += 1 - multichar_mapping << rich_pos - rich_char = rich_line[rich_pos] - end - - mapping[raw_pos] = multichar_mapping - else - mapping[raw_pos] = rich_pos - end - - rich_pos += 1 - end - - mapping - end - end - - # Takes an array of integers, and returns an array of ranges covering the same integers - def collapse_ranges(positions) - return [] if positions.empty? - ranges = [] - - start = prev = positions[0] - range = start..prev - positions[1..-1].each do |pos| - if pos == prev + 1 - range = start..pos - prev = pos - else - ranges << range - start = prev = pos - range = start..prev - end - end - ranges << range - - ranges - end - - # Inserts tags around the characters identified by the given range - def insert_around_range(text, range, before, after, offset = 0) - # Just to be sure - return offset if offset + range.end + 1 > text.length - - text.insert(offset + range.begin, before) - offset += before.length - - text.insert(offset + range.end + 1, after) - offset += after.length - - offset - end end end end diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index 0a15c6d9358..bd52ae47e9f 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -59,6 +59,10 @@ module Gitlab type == 'match' end + def discussable? + !['match', 'new-nonewline', 'old-nonewline'].include?(type) + end + def as_json(opts = nil) { type: type, diff --git a/lib/gitlab/diff/position.rb b/lib/gitlab/diff/position.rb index fc728123c97..f80afb20f0c 100644 --- a/lib/gitlab/diff/position.rb +++ b/lib/gitlab/diff/position.rb @@ -12,20 +12,26 @@ module Gitlab attr_reader :head_sha def initialize(attrs = {}) + if diff_file = attrs[:diff_file] + attrs[:diff_refs] = diff_file.diff_refs + attrs[:old_path] = diff_file.old_path + attrs[:new_path] = diff_file.new_path + end + + if diff_refs = attrs[:diff_refs] + attrs[:base_sha] = diff_refs.base_sha + attrs[:start_sha] = diff_refs.start_sha + attrs[:head_sha] = diff_refs.head_sha + end + @old_path = attrs[:old_path] @new_path = attrs[:new_path] + @base_sha = attrs[:base_sha] + @start_sha = attrs[:start_sha] + @head_sha = attrs[:head_sha] + @old_line = attrs[:old_line] @new_line = attrs[:new_line] - - if attrs[:diff_refs] - @base_sha = attrs[:diff_refs].base_sha - @start_sha = attrs[:diff_refs].start_sha - @head_sha = attrs[:diff_refs].head_sha - else - @base_sha = attrs[:base_sha] - @start_sha = attrs[:start_sha] - @head_sha = attrs[:head_sha] - end end # `Gitlab::Diff::Position` objects are stored as serialized attributes in @@ -129,33 +135,19 @@ module Gitlab end def diff_line(repository) - @diff_line ||= diff_file(repository).line_for_position(self) + @diff_line ||= diff_file(repository)&.line_for_position(self) end def line_code(repository) - @line_code ||= diff_file(repository).line_code_for_position(self) + @line_code ||= diff_file(repository)&.line_code_for_position(self) end private def find_diff_file(repository) - # We're at the initial commit, so just get that as we can't compare to anything. - compare = - if Gitlab::Git.blank_ref?(start_sha) - Gitlab::Git::Commit.find(repository.raw_repository, head_sha) - else - Gitlab::Git::Compare.new( - repository.raw_repository, - start_sha, - head_sha - ) - end - - diff = compare.diffs(paths: paths).first - - return unless diff + return unless diff_refs.complete? - Gitlab::Diff::File.new(diff, repository: repository, diff_refs: diff_refs) + diff_refs.compare_in(repository.project).diffs(paths: paths, expanded: true).diff_files.first end end end diff --git a/lib/gitlab/diff/position_tracer.rb b/lib/gitlab/diff/position_tracer.rb index e89ff238ec7..c7af5127bf1 100644 --- a/lib/gitlab/diff/position_tracer.rb +++ b/lib/gitlab/diff/position_tracer.rb @@ -3,21 +3,27 @@ module Gitlab module Diff class PositionTracer - attr_accessor :repository + attr_accessor :project attr_accessor :old_diff_refs attr_accessor :new_diff_refs attr_accessor :paths - def initialize(repository:, old_diff_refs:, new_diff_refs:, paths: nil) - @repository = repository + def initialize(project:, old_diff_refs:, new_diff_refs:, paths: nil) + @project = project @old_diff_refs = old_diff_refs @new_diff_refs = new_diff_refs @paths = paths end +<<<<<<< HEAD def trace(old_position) return unless old_diff_refs&.complete? && new_diff_refs&.complete? return unless old_position.diff_refs == old_diff_refs +======= + def trace(ab_position) + return unless old_diff_refs&.complete? && new_diff_refs&.complete? + return unless ab_position.diff_refs == old_diff_refs +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 # Suppose we have an MR with source branch `feature` and target branch `master`. # When the MR was created, the head of `master` was commit A, and the @@ -44,14 +50,16 @@ module Gitlab # # For diff notes for diff A->B, the position looks like this: # Position - # base_sha - ID of commit A + # start_sha - ID of commit A # head_sha - ID of commit B + # base_sha - ID of base commit of A and B # old_path - path as of A (nil if file was newly created) # new_path - path as of B (nil if file was deleted) # old_line - line number as of A (nil if file was newly created) # new_line - line number as of B (nil if file was deleted) # - # We can easily update `base_sha` and `head_sha` to hold the IDs of commits C and D, + # We can easily update `start_sha` and `head_sha` to hold the IDs of + # commits C and D, and can trivially determine `base_sha` based on those, # but need to find the paths and line numbers as of C and D. # # If the file was unchanged or newly created in A->B, the path as of D can be found @@ -68,107 +76,161 @@ module Gitlab # by generating diff A->C ("base to base"), finding the diff file with # `diff_file.old_path == position.old_path`, and taking `diff_file.new_path`. # The path as of D can be found by taking diff C->D, finding the diff file - # with that same `old_path` and taking `diff_file.new_path`. + # with `old_path` set to that `diff_file.new_path` and taking `diff_file.new_path`. # The line number as of C can be found by using the LineMapper on diff A->C # and providing the line number as of A. # The line number as of D can be found by using the LineMapper on diff C->D # and providing the line number as of C. - results = nil - results ||= trace_added_line(old_position) if old_position.added? || old_position.unchanged? - results ||= trace_removed_line(old_position) if old_position.removed? || old_position.unchanged? - - return unless results - - file_diff, old_line, new_line = results - - new_position = Position.new( - old_path: file_diff.old_path, - new_path: file_diff.new_path, - head_sha: new_diff_refs.head_sha, - start_sha: new_diff_refs.start_sha, - base_sha: new_diff_refs.base_sha, - old_line: old_line, - new_line: new_line - ) - - # If a position is found, but is not actually contained in the diff, for example - # because it was an unchanged line in the context of a change that was undone, - # we cannot return this as a successful trace. - return unless new_position.diff_line(repository) - - new_position + if ab_position.added? + trace_added_line(ab_position) + elsif ab_position.removed? + trace_removed_line(ab_position) + else # unchanged + trace_unchanged_line(ab_position) + end end private - def trace_added_line(old_position) - file_path = old_position.new_path - - return unless diff_head_to_head - - file_head_to_head = diff_head_to_head.find { |diff_file| diff_file.old_path == file_path } - - file_path = file_head_to_head.new_path if file_head_to_head - - new_line = LineMapper.new(file_head_to_head).old_to_new(old_position.new_line) - - return unless new_line - - file_diff = new_diffs.find { |diff_file| diff_file.new_path == file_path } - return unless file_diff - - old_line = LineMapper.new(file_diff).new_to_old(new_line) - - [file_diff, old_line, new_line] + def trace_added_line(ab_position) + b_path = ab_position.new_path + b_line = ab_position.new_line + + bd_diff = bd_diffs.diff_file_with_old_path(b_path) + + d_path = bd_diff&.new_path || b_path + d_line = LineMapper.new(bd_diff).old_to_new(b_line) + + if d_line + cd_diff = cd_diffs.diff_file_with_new_path(d_path) + + c_path = cd_diff&.old_path || d_path + c_line = LineMapper.new(cd_diff).new_to_old(d_line) + + if c_line + # If the line is still in D but also in C, it has turned from an + # added line into an unchanged one. + new_position = position(cd_diff, c_line, d_line) + if valid_position?(new_position) + # If the line is still in the MR, we don't treat this as outdated. + { position: new_position, outdated: false } + else + # If the line is no longer in the MR, we unfortunately cannot show + # the current state on the CD diff, so we treat it as outdated. + ac_diff = ac_diffs.diff_file_with_new_path(c_path) + + { position: position(ac_diff, nil, c_line), outdated: true } + end + else + # If the line is still in D and not in C, it is still added. + { position: position(cd_diff, nil, d_line), outdated: false } + end + else + # If the line is no longer in D, it has been removed from the MR. + { position: position(bd_diff, b_line, nil), outdated: true } + end end - def trace_removed_line(old_position) - file_path = old_position.old_path + def trace_removed_line(ab_position) + a_path = ab_position.old_path + a_line = ab_position.old_line - return unless diff_base_to_base + ac_diff = ac_diffs.diff_file_with_old_path(a_path) - file_base_to_base = diff_base_to_base.find { |diff_file| diff_file.old_path == file_path } + c_path = ac_diff&.new_path || a_path + c_line = LineMapper.new(ac_diff).old_to_new(a_line) - file_path = file_base_to_base.old_path if file_base_to_base + if c_line + cd_diff = cd_diffs.diff_file_with_old_path(c_path) - old_line = LineMapper.new(file_base_to_base).old_to_new(old_position.old_line) + d_path = cd_diff&.new_path || c_path + d_line = LineMapper.new(cd_diff).old_to_new(c_line) - return unless old_line + if d_line + # If the line is still in C but also in D, it has turned from a + # removed line into an unchanged one. + bd_diff = bd_diffs.diff_file_with_new_path(d_path) - file_diff = new_diffs.find { |diff_file| diff_file.old_path == file_path } - return unless file_diff - - new_line = LineMapper.new(file_diff).old_to_new(old_line) + { position: position(bd_diff, nil, d_line), outdated: true } + else + # If the line is still in C and not in D, it is still removed. + { position: position(cd_diff, c_line, nil), outdated: false } + end + else + # If the line is no longer in C, it has been removed outside of the MR. + { position: position(ac_diff, a_line, nil), outdated: true } + end + end - [file_diff, old_line, new_line] + def trace_unchanged_line(ab_position) + a_path = ab_position.old_path + a_line = ab_position.old_line + b_path = ab_position.new_path + b_line = ab_position.new_line + + ac_diff = ac_diffs.diff_file_with_old_path(a_path) + + c_path = ac_diff&.new_path || a_path + c_line = LineMapper.new(ac_diff).old_to_new(a_line) + + bd_diff = bd_diffs.diff_file_with_old_path(b_path) + + d_line = LineMapper.new(bd_diff).old_to_new(b_line) + + cd_diff = cd_diffs.diff_file_with_old_path(c_path) + + if c_line && d_line + # If the line is still in C and D, it is still unchanged. + new_position = position(cd_diff, c_line, d_line) + if valid_position?(new_position) + # If the line is still in the MR, we don't treat this as outdated. + { position: new_position, outdated: false } + else + # If the line is no longer in the MR, we unfortunately cannot show + # the current state on the CD diff or any change on the BD diff, + # so we treat it as outdated. + { position: nil, outdated: true } + end + elsif d_line # && !c_line + # If the line is still in D but no longer in C, it has turned from + # an unchanged line into an added one. + # We don't treat this as outdated since the line is still in the MR. + { position: position(cd_diff, nil, d_line), outdated: false } + else # !d_line && (c_line || !c_line) + # If the line is no longer in D, it has turned from an unchanged line + # into a removed one. + { position: position(bd_diff, b_line, nil), outdated: true } + end end - def diff_base_to_base - @diff_base_to_base ||= diff_files(old_diff_refs.base_sha || old_diff_refs.start_sha, new_diff_refs.base_sha || new_diff_refs.start_sha) + def ac_diffs + @ac_diffs ||= compare( + old_diff_refs.base_sha || old_diff_refs.start_sha, + new_diff_refs.base_sha || new_diff_refs.start_sha, + straight: true + ) end - def diff_head_to_head - @diff_head_to_head ||= diff_files(old_diff_refs.head_sha, new_diff_refs.head_sha) + def bd_diffs + @bd_diffs ||= compare(old_diff_refs.head_sha, new_diff_refs.head_sha, straight: true) end - def new_diffs - @new_diffs ||= diff_files(new_diff_refs.start_sha, new_diff_refs.head_sha, use_base: true) + def cd_diffs + @cd_diffs ||= compare(new_diff_refs.start_sha, new_diff_refs.head_sha) end - def diff_files(start_sha, head_sha, use_base: false) - base_sha = self.repository.merge_base(start_sha, head_sha) || start_sha + def compare(start_sha, head_sha, straight: false) + compare = CompareService.new(project, head_sha).execute(project, start_sha, straight: straight) + compare.diffs(paths: paths, expanded: true) + end - diffs = self.repository.raw_repository.diff( - use_base ? base_sha : start_sha, - head_sha, - {}, - *paths - ) + def position(diff_file, old_line, new_line) + Position.new(diff_file: diff_file, old_line: old_line, new_line: new_line) + end - diffs.decorate! do |diff| - Gitlab::Diff::File.new(diff, repository: self.repository) - end + def valid_position?(position) + !!position.diff_line(project.repository) end end end diff --git a/lib/gitlab/ee_compat_check.rb b/lib/gitlab/ee_compat_check.rb index 496ee0bdcb0..38e27513281 100644 --- a/lib/gitlab/ee_compat_check.rb +++ b/lib/gitlab/ee_compat_check.rb @@ -131,10 +131,12 @@ module Gitlab def check_patch(patch_path) step("Checking out master", %w[git checkout master]) step("Resetting to latest master", %w[git reset --hard origin/master]) + step("Fetching CE/#{ce_branch}", %W[git fetch #{CE_REPO} #{ce_branch}]) step( "Checking if #{patch_path} applies cleanly to EE/master", %W[git apply --check --3way #{patch_path}] ) do |output, status| + puts output unless status.zero? @failed_files = output.lines.reduce([]) do |memo, line| if line.start_with?('error: patch failed:') @@ -310,6 +312,17 @@ module Gitlab Resolve them, stage the changes and commit them. + If the patch couldn't be applied cleanly, use the following command: + + # In the EE repo + $ git apply --reject path/to/#{ce_branch}.patch + + This option makes git apply the parts of the patch that are applicable, + and leave the rejected hunks in corresponding `.rej` files. + You can then resolve the conflicts highlighted in `.rej` by + manually applying the correct diff from the `.rej` file to the file with conflicts. + When finished, you can delete the `.rej` files and commit your changes. + ⚠️ Don't forget to push your branch to gitlab-ee: # In the EE repo diff --git a/lib/gitlab/email/message/repository_push.rb b/lib/gitlab/email/message/repository_push.rb index 6c69cd9e6a9..ea035e33eff 100644 --- a/lib/gitlab/email/message/repository_push.rb +++ b/lib/gitlab/email/message/repository_push.rb @@ -42,7 +42,7 @@ module Gitlab return unless compare # This diff is more moderated in number of files and lines - @diffs ||= compare.diffs(max_files: 30, max_lines: 5000, no_collapse: true).diff_files + @diffs ||= compare.diffs(max_files: 30, max_lines: 5000, expanded: true).diff_files end def diffs_count diff --git a/lib/gitlab/encoding_helper.rb b/lib/gitlab/encoding_helper.rb new file mode 100644 index 00000000000..781f9c56a42 --- /dev/null +++ b/lib/gitlab/encoding_helper.rb @@ -0,0 +1,62 @@ +module Gitlab + module EncodingHelper + extend self + + # This threshold is carefully tweaked to prevent usage of encodings detected + # by CharlockHolmes with low confidence. If CharlockHolmes confidence is low, + # we're better off sticking with utf8 encoding. + # Reason: git diff can return strings with invalid utf8 byte sequences if it + # truncates a diff in the middle of a multibyte character. In this case + # CharlockHolmes will try to guess the encoding and will likely suggest an + # obscure encoding with low confidence. + # There is a lot more info with this merge request: + # https://gitlab.com/gitlab-org/gitlab_git/merge_requests/77#note_4754193 + ENCODING_CONFIDENCE_THRESHOLD = 40 + + def encode!(message) + return nil unless message.respond_to? :force_encoding + + # if message is utf-8 encoding, just return it + message.force_encoding("UTF-8") + return message if message.valid_encoding? + + # return message if message type is binary + detect = CharlockHolmes::EncodingDetector.detect(message) + return message.force_encoding("BINARY") if detect && detect[:type] == :binary + + # force detected encoding if we have sufficient confidence. + if detect && detect[:encoding] && detect[:confidence] > ENCODING_CONFIDENCE_THRESHOLD + message.force_encoding(detect[:encoding]) + end + + # encode and clean the bad chars + message.replace clean(message) + rescue + encoding = detect ? detect[:encoding] : "unknown" + "--broken encoding: #{encoding}" + end + + def encode_utf8(message) + detect = CharlockHolmes::EncodingDetector.detect(message) + if detect && detect[:encoding] + begin + CharlockHolmes::Converter.convert(message, detect[:encoding], 'UTF-8') + rescue ArgumentError => e + Rails.logger.warn("Ignoring error converting #{detect[:encoding]} into UTF8: #{e.message}") + + '' + end + else + clean(message) + end + end + + private + + def clean(message) + message.encode("UTF-16BE", undef: :replace, invalid: :replace, replace: "") + .encode("UTF-8") + .gsub("\0".encode("UTF-8"), "") + end + end +end diff --git a/lib/gitlab/etag_caching/middleware.rb b/lib/gitlab/etag_caching/middleware.rb index 270d67dd50c..7f884183bb1 100644 --- a/lib/gitlab/etag_caching/middleware.rb +++ b/lib/gitlab/etag_caching/middleware.rb @@ -6,12 +6,13 @@ module Gitlab end def call(env) - route = Gitlab::EtagCaching::Router.match(env) + request = Rack::Request.new(env) + route = Gitlab::EtagCaching::Router.match(request) return @app.call(env) unless route track_event(:etag_caching_middleware_used, route) - etag, cached_value_present = get_etag(env) + etag, cached_value_present = get_etag(request) if_none_match = env['HTTP_IF_NONE_MATCH'] if if_none_match == etag @@ -27,8 +28,8 @@ module Gitlab private - def get_etag(env) - cache_key = env['PATH_INFO'] + def get_etag(request) + cache_key = request.path store = Gitlab::EtagCaching::Store.new current_value = store.get(cache_key) cached_value_present = current_value.present? diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb index d74e31af5c6..1cb361fc216 100644 --- a/lib/gitlab/etag_caching/router.rb +++ b/lib/gitlab/etag_caching/router.rb @@ -7,18 +7,28 @@ module Gitlab # - Don't contain a reserved word (expect for the words used in the # regex itself) # - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route +<<<<<<< HEAD # - Ending in `issues/id`/rendered_title` for the `issue_title` route USED_IN_ROUTES = %w[noteable issue notes issues rendered_title commit pipelines merge_requests new].freeze RESERVED_WORDS = Gitlab::Regex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS) +======= + # - Ending in `issues/id`/realtime_changes` for the `issue_title` route + USED_IN_ROUTES = %w[noteable issue notes issues realtime_changes + commit pipelines merge_requests builds + new environments].freeze + RESERVED_WORDS = Gitlab::PathRegex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES + RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape))) + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 ROUTES = [ Gitlab::EtagCaching::Router::Route.new( %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z), 'issue_notes' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/issues/\d+/rendered_title\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/issues/\d+/realtime_changes\z), 'issue_title' ), Gitlab::EtagCaching::Router::Route.new( @@ -41,10 +51,18 @@ module Gitlab %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines/\d+\.json\z), 'project_pipeline' ), + Gitlab::EtagCaching::Router::Route.new( + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/builds/\d+\.json\z), + 'project_build' + ), + Gitlab::EtagCaching::Router::Route.new( + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z), + 'environments' + ) ].freeze - def self.match(env) - ROUTES.find { |route| route.regexp.match(env['PATH_INFO']) } + def self.match(request) + ROUTES.find { |route| route.regexp.match(request.path_info) } end end end diff --git a/lib/gitlab/file_detector.rb b/lib/gitlab/file_detector.rb index c9ca4cadd1c..a8cb7fc3fe7 100644 --- a/lib/gitlab/file_detector.rb +++ b/lib/gitlab/file_detector.rb @@ -5,15 +5,33 @@ module Gitlab # a README or a CONTRIBUTING file. module FileDetector PATTERNS = { + # Project files readme: /\Areadme/i, changelog: /\A(changelog|history|changes|news)/i, license: /\A(licen[sc]e|copying)(\..+|\z)/i, contributing: /\Acontributing/i, version: 'version', + avatar: /\Alogo\.(png|jpg|gif)\z/, + + # Configuration files gitignore: '.gitignore', koding: '.koding.yml', gitlab_ci: '.gitlab-ci.yml', - avatar: /\Alogo\.(png|jpg|gif)\z/ + route_map: 'route-map.yml', + + # Dependency files + cartfile: /\ACartfile/, + composer_json: 'composer.json', + gemfile: /\A(Gemfile|gems\.rb)\z/, + gemfile_lock: 'Gemfile.lock', + gemspec: /\.gemspec\z/, + godeps_json: 'Godeps.json', + package_json: 'package.json', + podfile: 'Podfile', + podspec_json: /\.podspec\.json\z/, + podspec: /\.podspec\z/, + requirements_txt: /requirements\.txt\z/, + yarn_lock: 'yarn.lock' }.freeze # Returns an Array of file types based on the given paths. diff --git a/lib/gitlab/file_finder.rb b/lib/gitlab/file_finder.rb new file mode 100644 index 00000000000..093d9ed8092 --- /dev/null +++ b/lib/gitlab/file_finder.rb @@ -0,0 +1,32 @@ +# This class finds files in a repository by name and content +# the result is joined and sorted by file name +module Gitlab + class FileFinder + BATCH_SIZE = 100 + + attr_reader :project, :ref + + def initialize(project, ref) + @project = project + @ref = ref + end + + def find(query) + blobs = project.repository.search_files_by_content(query, ref).first(BATCH_SIZE) + found_file_names = Set.new + + results = blobs.map do |blob| + blob = Gitlab::ProjectSearchResults.parse_search_result(blob) + found_file_names << blob.filename + + [blob.filename, blob] + end + + project.repository.search_files_by_name(query, ref).first(BATCH_SIZE).each do |filename| + results << [filename, OpenStruct.new(ref: ref)] unless found_file_names.include?(filename) + end + + results.sort_by(&:first) + end + end +end diff --git a/lib/gitlab/git/blame.rb b/lib/gitlab/git/blame.rb index 58193391926..66829a03c2e 100644 --- a/lib/gitlab/git/blame.rb +++ b/lib/gitlab/git/blame.rb @@ -1,7 +1,7 @@ module Gitlab module Git class Blame - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper attr_reader :lines, :blames diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index 12458f9f410..d60e607b02b 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -2,7 +2,7 @@ module Gitlab module Git class Blob include Linguist::BlobHelper - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper # This number is the maximum amount of data that we want to display to # the user. We load as much as we can for encoding detection @@ -88,9 +88,10 @@ module Gitlab new( id: blob_entry[:oid], name: blob_entry[:name], + size: 0, data: '', path: path, - commit_id: sha, + commit_id: sha ) end end diff --git a/lib/gitlab/git/branch.rb b/lib/gitlab/git/branch.rb index 586380da94a..124526e4b59 100644 --- a/lib/gitlab/git/branch.rb +++ b/lib/gitlab/git/branch.rb @@ -1,6 +1,40 @@ module Gitlab module Git class Branch < Ref + def initialize(repository, name, target) + if target.is_a?(Gitaly::FindLocalBranchResponse) + target = target_from_gitaly_local_branches_response(target) + end + + super(repository, name, target) + end + + def target_from_gitaly_local_branches_response(response) + # Git messages have no encoding enforcements. However, in the UI we only + # handle UTF-8, so basically we cross our fingers that the message force + # encoded to UTF-8 is readable. + message = response.commit_subject.dup.force_encoding('UTF-8') + + # NOTE: For ease of parsing in Gitaly, we have only the subject of + # the commit and not the full message. This is ok, since all the + # code that uses `local_branches` only cares at most about the + # commit message. + # TODO: Once gitaly "takes over" Rugged consider separating the + # subject from the message to make it clearer when there's one + # available but not the other. + hash = { + id: response.commit_id, + message: message, + authored_date: Time.at(response.commit_author.date.seconds), + author_name: response.commit_author.name, + author_email: response.commit_author.email, + committed_date: Time.at(response.commit_committer.date.seconds), + committer_name: response.commit_committer.name, + committer_email: response.commit_committer.email + } + + Gitlab::Git::Commit.decorate(hash) + end end end end diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 3a73697dc5d..bb04731f08c 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -2,7 +2,7 @@ module Gitlab module Git class Commit - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper attr_accessor :raw_commit, :head, :refs @@ -19,13 +19,7 @@ module Gitlab def ==(other) return false unless other.is_a?(Gitlab::Git::Commit) - methods = [:message, :parent_ids, :authored_date, :author_name, - :author_email, :committed_date, :committer_name, - :committer_email] - - methods.all? do |method| - send(method) == other.send(method) - end + id && id == other.id end class << self @@ -55,6 +49,7 @@ module Gitlab # Commit.find(repo, 'master') # def find(repo, commit_id = "HEAD") + return commit_id if commit_id.is_a?(Gitlab::Git::Commit) return decorate(commit_id) if commit_id.is_a?(Rugged::Commit) obj = if commit_id.is_a?(String) @@ -192,6 +187,10 @@ module Gitlab Commit.diff_from_parent(raw_commit, options) end + def deltas + @deltas ||= diff_from_parent.each_delta.map { |d| Gitlab::Git::Diff.new(d) } + end + def has_zero_stats? stats.total.zero? rescue diff --git a/lib/gitlab/git/compare.rb b/lib/gitlab/git/compare.rb index 696a2acd5e3..78e440395a5 100644 --- a/lib/gitlab/git/compare.rb +++ b/lib/gitlab/git/compare.rb @@ -3,7 +3,7 @@ module Gitlab class Compare attr_reader :head, :base, :straight - def initialize(repository, base, head, straight = false) + def initialize(repository, base, head, straight: false) @repository = repository @straight = straight diff --git a/lib/gitlab/git/diff.rb b/lib/gitlab/git/diff.rb index 019be151353..8926aa19925 100644 --- a/lib/gitlab/git/diff.rb +++ b/lib/gitlab/git/diff.rb @@ -3,7 +3,7 @@ module Gitlab module Git class Diff TimeoutError = Class.new(StandardError) - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper # Diff properties attr_accessor :old_path, :new_path, :a_mode, :b_mode, :diff @@ -11,15 +11,34 @@ module Gitlab # Stats properties attr_accessor :new_file, :renamed_file, :deleted_file - attr_accessor :too_large + alias_method :new_file?, :new_file + alias_method :deleted_file?, :deleted_file + alias_method :renamed_file?, :renamed_file - # The maximum size of a diff to display. - DIFF_SIZE_LIMIT = 102400 # 100 KB + attr_accessor :expanded - # The maximum size before a diff is collapsed. - DIFF_COLLAPSE_LIMIT = 10240 # 10 KB + # We need this accessor because of `to_hash` and `init_from_hash` + attr_accessor :too_large class << self + # The maximum size of a diff to display. + def size_limit + if Feature.enabled?('gitlab_git_diff_size_limit_increase') + 200.kilobytes + else + 100.kilobytes + end + end + + # The maximum size before a diff is collapsed. + def collapse_limit + if Feature.enabled?('gitlab_git_diff_size_limit_increase') + 100.kilobytes + else + 10.kilobytes + end + end + def between(repo, head, base, options = {}, *paths) straight = options.delete(:straight) || false @@ -148,7 +167,7 @@ module Gitlab :include_untracked_content, :skip_binary_check, :include_typechange, :include_typechange_trees, :ignore_filemode, :recurse_ignored_dirs, :paths, - :max_files, :max_lines, :all_diffs, :no_collapse] + :max_files, :max_lines, :limits, :expanded] if default_options actual_defaults = default_options.dup @@ -173,16 +192,20 @@ module Gitlab end end - def initialize(raw_diff, collapse: false) + def initialize(raw_diff, expanded: true) + @expanded = expanded + case raw_diff when Hash init_from_hash(raw_diff) - prune_diff_if_eligible(collapse) + prune_diff_if_eligible when Rugged::Patch, Rugged::Diff::Delta - init_from_rugged(raw_diff, collapse: collapse) - when Gitaly::CommitDiffResponse + init_from_rugged(raw_diff) + when Gitlab::GitalyClient::Diff + init_from_gitaly(raw_diff) + prune_diff_if_eligible + when Gitaly::CommitDelta init_from_gitaly(raw_diff) - prune_diff_if_eligible(collapse) when nil raise "Nil as raw diff passed" else @@ -206,6 +229,10 @@ module Gitlab hash end + def mode_changed? + a_mode && b_mode && a_mode != b_mode + end + def submodule? a_mode == '160000' || b_mode == '160000' end @@ -216,17 +243,13 @@ module Gitlab def too_large? if @too_large.nil? - @too_large = @diff.bytesize >= DIFF_SIZE_LIMIT + @too_large = @diff.bytesize >= self.class.size_limit else @too_large end end - def collapsible? - @diff.bytesize >= DIFF_COLLAPSE_LIMIT - end - - def prune_large_diff! + def too_large! @diff = '' @line_count = 0 @too_large = true @@ -234,10 +257,11 @@ module Gitlab def collapsed? return @collapsed if defined?(@collapsed) - false + + @collapsed = !expanded && @diff.bytesize >= self.class.collapse_limit end - def prune_collapsed_diff! + def collapse! @diff = '' @line_count = 0 @collapsed = true @@ -245,9 +269,9 @@ module Gitlab private - def init_from_rugged(rugged, collapse: false) + def init_from_rugged(rugged) if rugged.is_a?(Rugged::Patch) - init_from_rugged_patch(rugged, collapse: collapse) + init_from_rugged_patch(rugged) d = rugged.delta else d = rugged @@ -262,10 +286,10 @@ module Gitlab @deleted_file = d.deleted? end - def init_from_rugged_patch(patch, collapse: false) + def init_from_rugged_patch(patch) # Don't bother initializing diffs that are too large. If a diff is # binary we're not going to display anything so we skip the size check. - return if !patch.delta.binary? && prune_large_patch(patch, collapse) + return if !patch.delta.binary? && prune_large_patch(patch) @diff = encode!(strip_diff_headers(patch.to_s)) end @@ -278,40 +302,43 @@ module Gitlab end end - def init_from_gitaly(diff_msg) - @diff = diff_msg.raw_chunks.join - @new_path = encode!(diff_msg.to_path.dup) - @old_path = encode!(diff_msg.from_path.dup) - @a_mode = diff_msg.old_mode.to_s(8) - @b_mode = diff_msg.new_mode.to_s(8) - @new_file = diff_msg.from_id == BLANK_SHA - @renamed_file = diff_msg.from_path != diff_msg.to_path - @deleted_file = diff_msg.to_id == BLANK_SHA + def init_from_gitaly(diff) + @diff = diff.patch if diff.respond_to?(:patch) + @new_path = encode!(diff.to_path.dup) + @old_path = encode!(diff.from_path.dup) + @a_mode = diff.old_mode.to_s(8) + @b_mode = diff.new_mode.to_s(8) + @new_file = diff.from_id == BLANK_SHA + @renamed_file = diff.from_path != diff.to_path + @deleted_file = diff.to_id == BLANK_SHA end - def prune_diff_if_eligible(collapse = false) - prune_large_diff! if too_large? - prune_collapsed_diff! if collapse && collapsible? + def prune_diff_if_eligible + if too_large? + too_large! + elsif collapsed? + collapse! + end end # If the patch surpasses any of the diff limits it calls the appropiate # prune method and returns true. Otherwise returns false. - def prune_large_patch(patch, collapse) + def prune_large_patch(patch) size = 0 patch.each_hunk do |hunk| hunk.each_line do |line| size += line.content.bytesize - if size >= DIFF_SIZE_LIMIT - prune_large_diff! + if size >= self.class.size_limit + too_large! return true end end end - if collapse && size >= DIFF_COLLAPSE_LIMIT - prune_collapsed_diff! + if !expanded && size >= self.class.collapse_limit + collapse! return true end diff --git a/lib/gitlab/git/diff_collection.rb b/lib/gitlab/git/diff_collection.rb index 4e45ec7c174..334e06a6eca 100644 --- a/lib/gitlab/git/diff_collection.rb +++ b/lib/gitlab/git/diff_collection.rb @@ -9,35 +9,29 @@ module Gitlab @iterator = iterator @max_files = options.fetch(:max_files, DEFAULT_LIMITS[:max_files]) @max_lines = options.fetch(:max_lines, DEFAULT_LIMITS[:max_lines]) - @max_bytes = @max_files * 5120 # Average 5 KB per file + @max_bytes = @max_files * 5.kilobytes # Average 5 KB per file @safe_max_files = [@max_files, DEFAULT_LIMITS[:max_files]].min @safe_max_lines = [@max_lines, DEFAULT_LIMITS[:max_lines]].min - @safe_max_bytes = @safe_max_files * 5120 # Average 5 KB per file - @all_diffs = !!options.fetch(:all_diffs, false) - @no_collapse = !!options.fetch(:no_collapse, true) - @deltas_only = !!options.fetch(:deltas_only, false) + @safe_max_bytes = @safe_max_files * 5.kilobytes # Average 5 KB per file + @enforce_limits = !!options.fetch(:limits, true) + @expanded = !!options.fetch(:expanded, true) @line_count = 0 @byte_count = 0 @overflow = false + @empty = true @array = Array.new end def each(&block) - if @populated - # @iterator.each is slower than just iterating the array in place - @array.each(&block) - elsif @deltas_only - each_delta(&block) - else - Gitlab::GitalyClient.migrate(:commit_raw_diffs) do - each_patch(&block) - end + Gitlab::GitalyClient.migrate(:commit_raw_diffs) do + each_patch(&block) end end def empty? - !@iterator.any? + any? # Make sure the iterator has been exercised + @empty end def overflow? @@ -63,17 +57,17 @@ module Gitlab collection = each_with_index do |element, i| @array[i] = yield(element) end - @populated = true collection end + alias_method :to_ary, :to_a + private def populate! return if @populated each { nil } # force a loop through all diffs - @populated = true nil end @@ -81,42 +75,36 @@ module Gitlab files >= @safe_max_files || @line_count > @safe_max_lines || @byte_count >= @safe_max_bytes end - def each_delta - @iterator.each_delta.with_index do |delta, i| - diff = Gitlab::Git::Diff.new(delta) - - yield @array[i] = diff + def each_patch + i = 0 + @array.each do |diff| + yield diff + i += 1 end - end - def each_patch - @iterator.each_with_index do |raw, i| - # First yield cached Diff instances from @array - if @array[i] - yield @array[i] - next - end + return if @overflow + return if @iterator.nil? - # We have exhausted @array, time to create new Diff instances or stop. - break if @overflow + @iterator.each do |raw| + @empty = false - if !@all_diffs && i >= @max_files + if @enforce_limits && i >= @max_files @overflow = true break end - collapse = !@all_diffs && !@no_collapse + expanded = !@enforce_limits || @expanded - diff = Gitlab::Git::Diff.new(raw, collapse: collapse) + diff = Gitlab::Git::Diff.new(raw, expanded: expanded) - if collapse && over_safe_limits?(i) - diff.prune_collapsed_diff! + if !expanded && over_safe_limits?(i) + diff.collapse! end @line_count += diff.line_count @byte_count += diff.diff.bytesize - if !@all_diffs && (@line_count >= @max_lines || @byte_count >= @max_bytes) + if @enforce_limits && (@line_count >= @max_lines || @byte_count >= @max_bytes) # This last Diff instance pushes us over the lines limit. We stop and # discard it. @overflow = true @@ -124,7 +112,13 @@ module Gitlab end yield @array[i] = diff + i += 1 end + + @populated = true + + # Allow iterator to be garbage-collected. It cannot be reused anyway. + @iterator = nil end end end diff --git a/lib/gitlab/git/encoding_helper.rb b/lib/gitlab/git/encoding_helper.rb deleted file mode 100644 index f918074cb14..00000000000 --- a/lib/gitlab/git/encoding_helper.rb +++ /dev/null @@ -1,64 +0,0 @@ -module Gitlab - module Git - module EncodingHelper - extend self - - # This threshold is carefully tweaked to prevent usage of encodings detected - # by CharlockHolmes with low confidence. If CharlockHolmes confidence is low, - # we're better off sticking with utf8 encoding. - # Reason: git diff can return strings with invalid utf8 byte sequences if it - # truncates a diff in the middle of a multibyte character. In this case - # CharlockHolmes will try to guess the encoding and will likely suggest an - # obscure encoding with low confidence. - # There is a lot more info with this merge request: - # https://gitlab.com/gitlab-org/gitlab_git/merge_requests/77#note_4754193 - ENCODING_CONFIDENCE_THRESHOLD = 40 - - def encode!(message) - return nil unless message.respond_to? :force_encoding - - # if message is utf-8 encoding, just return it - message.force_encoding("UTF-8") - return message if message.valid_encoding? - - # return message if message type is binary - detect = CharlockHolmes::EncodingDetector.detect(message) - return message.force_encoding("BINARY") if detect && detect[:type] == :binary - - # force detected encoding if we have sufficient confidence. - if detect && detect[:encoding] && detect[:confidence] > ENCODING_CONFIDENCE_THRESHOLD - message.force_encoding(detect[:encoding]) - end - - # encode and clean the bad chars - message.replace clean(message) - rescue - encoding = detect ? detect[:encoding] : "unknown" - "--broken encoding: #{encoding}" - end - - def encode_utf8(message) - detect = CharlockHolmes::EncodingDetector.detect(message) - if detect - begin - CharlockHolmes::Converter.convert(message, detect[:encoding], 'UTF-8') - rescue ArgumentError => e - Rails.logger.warn("Ignoring error converting #{detect[:encoding]} into UTF8: #{e.message}") - - '' - end - else - clean(message) - end - end - - private - - def clean(message) - message.encode("UTF-16BE", undef: :replace, invalid: :replace, replace: "") - .encode("UTF-8") - .gsub("\0".encode("UTF-8"), "") - end - end - end -end diff --git a/lib/gitlab/git/ref.rb b/lib/gitlab/git/ref.rb index 37ef6836742..ebf7393dc61 100644 --- a/lib/gitlab/git/ref.rb +++ b/lib/gitlab/git/ref.rb @@ -1,7 +1,7 @@ module Gitlab module Git class Ref - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper # Branch or tag name # without "refs/tags|heads" prefix diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 239dc663598..9d6adbdb4ac 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -80,14 +80,16 @@ module Gitlab end # Returns an Array of Branches - def branches - rugged.branches.map do |rugged_ref| + def branches(filter: nil, sort_by: nil) + branches = rugged.branches.each(filter).map do |rugged_ref| begin Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target) rescue Rugged::ReferenceError # Omit invalid branch end - end.compact.sort_by(&:name) + end.compact + + sort_branches(branches, sort_by) end def reload_rugged @@ -108,15 +110,21 @@ module Gitlab Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target) if rugged_ref end - def local_branches - rugged.branches.each(:local).map do |branch| - Gitlab::Git::Branch.new(self, branch.name, branch.target) + def local_branches(sort_by: nil) + gitaly_migrate(:local_branches) do |is_enabled| + if is_enabled + gitaly_ref_client.local_branches(sort_by: sort_by).map do |gitaly_branch| + Gitlab::Git::Branch.new(self, gitaly_branch.name, gitaly_branch) + end + else + branches(filter: :local, sort_by: sort_by) + end end end # Returns the number of valid branches def branch_count - Gitlab::GitalyClient.migrate(:branch_names) do |is_enabled| + gitaly_migrate(:branch_names) do |is_enabled| if is_enabled gitaly_ref_client.count_branch_names else @@ -135,7 +143,7 @@ module Gitlab # Returns the number of valid tags def tag_count - Gitlab::GitalyClient.migrate(:tag_names) do |is_enabled| + gitaly_migrate(:tag_names) do |is_enabled| if is_enabled gitaly_ref_client.count_tag_names else @@ -260,7 +268,7 @@ module Gitlab 'RepoPath' => path, 'ArchivePrefix' => prefix, 'ArchivePath' => archive_file_path(prefix, storage_path, format), - 'CommitId' => commit.id, + 'CommitId' => commit.id } end @@ -998,31 +1006,39 @@ module Gitlab # Parses the contents of a .gitmodules file and returns a hash of # submodule information. def parse_gitmodules(commit, content) - results = {} + modules = {} - current = "" - content.split("\n").each do |txt| - if txt =~ /^\s*\[/ - current = txt.match(/(?<=").*(?=")/)[0] - results[current] = {} - else - next unless results[current] - match_data = txt.match(/(\w+)\s*=\s*(.*)/) - next unless match_data - target = match_data[2].chomp - results[current][match_data[1]] = target + name = nil + content.each_line do |line| + case line.strip + when /\A\[submodule "(?<name>[^"]+)"\]\z/ # Submodule header + name = $~[:name] + modules[name] = {} + when /\A(?<key>\w+)\s*=\s*(?<value>.*)\z/ # Key/value pair + key = $~[:key] + value = $~[:value].chomp + + next unless name && modules[name] - if match_data[1] == "path" + modules[name][key] = value + + if key == 'path' begin - results[current]["id"] = blob_content(commit, target) + modules[name]['id'] = blob_content(commit, value) rescue InvalidBlobName - results.delete(current) + # The current entry is invalid + modules.delete(name) + name = nil end end + when /\A#/ # Comment + next + else # Invalid line + name = nil end end - results + modules end # Returns true if +commit+ introduced changes to +path+, using commit @@ -1078,7 +1094,12 @@ module Gitlab elsif tmp_entry.nil? return nil else - tmp_entry = rugged.lookup(tmp_entry[:oid]) + begin + tmp_entry = rugged.lookup(tmp_entry[:oid]) + rescue Rugged::OdbError, Rugged::InvalidError, Rugged::ReferenceError + return nil + end + return nil unless tmp_entry.type == :tree tmp_entry = tmp_entry[dir] end @@ -1110,56 +1131,6 @@ module Gitlab end end - def archive_to_file(treeish = 'master', filename = 'archive.tar.gz', format = nil, compress_cmd = %w(gzip -n)) - git_archive_cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} archive) - - # Put files into a directory before archiving - prefix = "#{archive_name(treeish)}/" - git_archive_cmd << "--prefix=#{prefix}" - - # Format defaults to tar - git_archive_cmd << "--format=#{format}" if format - - git_archive_cmd += %W(-- #{treeish}) - - open(filename, 'w') do |file| - # Create a pipe to act as the '|' in 'git archive ... | gzip' - pipe_rd, pipe_wr = IO.pipe - - # Get the compression process ready to accept data from the read end - # of the pipe - compress_pid = spawn(*nice(compress_cmd), in: pipe_rd, out: file) - # The read end belongs to the compression process now; we should - # close our file descriptor for it. - pipe_rd.close - - # Start 'git archive' and tell it to write into the write end of the - # pipe. - git_archive_pid = spawn(*nice(git_archive_cmd), out: pipe_wr) - # The write end belongs to 'git archive' now; close it. - pipe_wr.close - - # When 'git archive' and the compression process are finished, we are - # done. - Process.waitpid(git_archive_pid) - raise "#{git_archive_cmd.join(' ')} failed" unless $?.success? - Process.waitpid(compress_pid) - raise "#{compress_cmd.join(' ')} failed" unless $?.success? - end - end - - def nice(cmd) - nice_cmd = %w(nice -n 20) - unless unsupported_platform? - nice_cmd += %w(ionice -c 2 -n 7) - end - nice_cmd + cmd - end - - def unsupported_platform? - %w[darwin freebsd solaris].map { |platform| RUBY_PLATFORM.include?(platform) }.any? - end - # Returns true if the index entry has the special file mode that denotes # a submodule. def submodule?(index_entry) @@ -1252,6 +1223,23 @@ module Gitlab diff.each_patch end + def sort_branches(branches, sort_by) + case sort_by + when 'name' + branches.sort_by(&:name) + when 'updated_desc' + branches.sort do |a, b| + b.dereferenced_target.committed_date <=> a.dereferenced_target.committed_date + end + when 'updated_asc' + branches.sort do |a, b| + a.dereferenced_target.committed_date <=> b.dereferenced_target.committed_date + end + else + branches + end + end + def gitaly_ref_client @gitaly_ref_client ||= Gitlab::GitalyClient::Ref.new(self) end diff --git a/lib/gitlab/git/tree.rb b/lib/gitlab/git/tree.rb index b722d8a9f56..b9afa05c819 100644 --- a/lib/gitlab/git/tree.rb +++ b/lib/gitlab/git/tree.rb @@ -1,7 +1,7 @@ module Gitlab module Git class Tree - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper attr_accessor :id, :root_id, :name, :path, :type, :mode, :commit_id, :submodule_url @@ -35,7 +35,7 @@ module Gitlab type: entry[:type], mode: entry[:filemode].to_s(8), path: path ? File.join(path, entry[:name]) : entry[:name], - commit_id: sha, + commit_id: sha ) end end diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 99724db8da2..0a19d24eb20 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -3,33 +3,39 @@ module Gitlab class GitAccess UnauthorizedError = Class.new(StandardError) + NotFoundError = Class.new(StandardError) ERROR_MESSAGES = { upload: 'You are not allowed to upload code for this project.', download: 'You are not allowed to download code from this project.', deploy_key_upload: 'This deploy key does not have write access to this project.', - no_repo: 'A repository for this project does not exist yet.' + no_repo: 'A repository for this project does not exist yet.', + project_not_found: 'The project you were looking for could not be found.', + account_blocked: 'Your account has been blocked.', + command_not_allowed: "The command you're trying to execute is not allowed.", + upload_pack_disabled_over_http: 'Pulling over HTTP is not allowed.', + receive_pack_disabled_over_http: 'Pushing over HTTP is not allowed.' }.freeze DOWNLOAD_COMMANDS = %w{ git-upload-pack git-upload-archive }.freeze PUSH_COMMANDS = %w{ git-receive-pack }.freeze ALL_COMMANDS = DOWNLOAD_COMMANDS + PUSH_COMMANDS - attr_reader :actor, :project, :protocol, :user_access, :authentication_abilities + attr_reader :actor, :project, :protocol, :authentication_abilities 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) check_protocol! check_active_user! check_project_accessibility! + check_command_disabled!(cmd) check_command_existence!(cmd) check_repository_existence! @@ -40,9 +46,7 @@ module Gitlab check_push_access!(changes) end - build_status_object(true) - rescue UnauthorizedError => ex - build_status_object(false, ex.message) + true end def guest_can_download_code? @@ -73,19 +77,39 @@ module Gitlab return if deploy_key? if user && !user_access.allowed? - raise UnauthorizedError, "Your account has been blocked." + raise UnauthorizedError, ERROR_MESSAGES[:account_blocked] end end def check_project_accessibility! if project.blank? || !can_read_project? - raise UnauthorizedError, 'The project you were looking for could not be found.' + raise NotFoundError, ERROR_MESSAGES[:project_not_found] + end + end + + def check_command_disabled!(cmd) + if upload_pack?(cmd) + check_upload_pack_disabled! + elsif receive_pack?(cmd) + check_receive_pack_disabled! + end + end + + def check_upload_pack_disabled! + if http? && upload_pack_disabled_over_http? + raise UnauthorizedError, ERROR_MESSAGES[:upload_pack_disabled_over_http] + end + end + + def check_receive_pack_disabled! + if http? && receive_pack_disabled_over_http? + raise UnauthorizedError, ERROR_MESSAGES[:receive_pack_disabled_over_http] end end def check_command_existence!(cmd) unless ALL_COMMANDS.include?(cmd) - raise UnauthorizedError, "The command you're trying to execute is not allowed." + raise UnauthorizedError, ERROR_MESSAGES[:command_not_allowed] end end @@ -138,11 +162,9 @@ module Gitlab # Iterate over all changes to find if user allowed all of them to be applied changes_list.each do |change| - status = check_single_change_access(change) - unless status.allowed? - # If user does not have access to make at least one change - cancel all push - raise UnauthorizedError, status.message - end + # If user does not have access to make at least one change, cancel all + # push by allowing the exception to bubble up + check_single_change_access(change) end end @@ -168,14 +190,40 @@ module Gitlab actor.is_a?(DeployKey) end + def ci? + actor == :ci + end + def can_read_project? - if deploy_key + if deploy_key? deploy_key.has_access_to?(project) elsif user user.can?(:read_project, project) + elsif ci? + true # allow CI (build without a user) for backwards compatibility end || Guest.can?(:read_project, project) end + def http? + protocol == 'http' + end + + def upload_pack?(command) + command == 'git-upload-pack' + end + + def receive_pack?(command) + command == 'git-receive-pack' + end + + def upload_pack_disabled_over_http? + !Gitlab.config.gitlab_shell.upload_pack + end + + def receive_pack_disabled_over_http? + !Gitlab.config.gitlab_shell.receive_pack + end + protected def user @@ -185,15 +233,19 @@ module Gitlab case actor when User actor - when DeployKey - nil when Key - actor.user + actor.user unless actor.is_a?(DeployKey) + when :ci + nil end end - def build_status_object(status, message = '') - Gitlab::GitAccessStatus.new(status, message) + def user_access + @user_access ||= if ci? + CiAccess.new + else + UserAccess.new(user, project: project) + end end end end diff --git a/lib/gitlab/git_access_status.rb b/lib/gitlab/git_access_status.rb deleted file mode 100644 index 09bb01be694..00000000000 --- a/lib/gitlab/git_access_status.rb +++ /dev/null @@ -1,15 +0,0 @@ -module Gitlab - class GitAccessStatus - attr_accessor :status, :message - alias_method :allowed?, :status - - def initialize(status, message = '') - @status = status - @message = message - end - - def to_json(opts = nil) - { status: @status, message: @message }.to_json(opts) - end - end -end diff --git a/lib/gitlab/git_access_wiki.rb b/lib/gitlab/git_access_wiki.rb index 67eaa5e088d..1fe5155c093 100644 --- a/lib/gitlab/git_access_wiki.rb +++ b/lib/gitlab/git_access_wiki.rb @@ -1,5 +1,9 @@ module Gitlab class GitAccessWiki < GitAccess + ERROR_MESSAGES = { + write_to_wiki: "You are not allowed to write to this project's wiki." + }.freeze + def guest_can_download_code? Guest.can?(:download_wiki_code, project) end @@ -9,11 +13,11 @@ module Gitlab end def check_single_change_access(change) - if user_access.can_do_action?(:create_wiki) - build_status_object(true) - else - build_status_object(false, "You are not allowed to write to this project's wiki.") + unless user_access.can_do_action?(:create_wiki) + raise UnauthorizedError, ERROR_MESSAGES[:write_to_wiki] end + + true end end end diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb index 0e14253ab4e..742118b76a8 100644 --- a/lib/gitlab/git_post_receive.rb +++ b/lib/gitlab/git_post_receive.rb @@ -13,6 +13,16 @@ module Gitlab super(identifier, project, revision) end + def changes_refs + return enum_for(:changes_refs) unless block_given? + + changes.each do |change| + oldrev, newrev, ref = change.strip.split(' ') + + yield oldrev, newrev, ref + end + end + private def deserialize_changes(changes) diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index 72466700c05..2343446bf22 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -2,6 +2,12 @@ require 'gitaly' module Gitlab module GitalyClient + module MigrationStatus + DISABLED = 1 + OPT_IN = 2 + OPT_OUT = 3 + end + SERVER_VERSION_FILE = 'GITALY_SERVER_VERSION'.freeze MUTEX = Mutex.new @@ -46,8 +52,20 @@ module Gitlab Gitlab.config.gitaly.enabled end - def self.feature_enabled?(feature) - enabled? && ENV["GITALY_#{feature.upcase}"] == '1' + def self.feature_enabled?(feature, status: MigrationStatus::OPT_IN) + return false if !enabled? || status == MigrationStatus::DISABLED + + feature = Feature.get("gitaly_#{feature}") + + # If the feature hasn't been set, turn it on if it's opt-out + return status == MigrationStatus::OPT_OUT unless Feature.persisted?(feature) + + if feature.percentage_of_time_value > 0 + # Probabilistically enable this feature + return Random.rand() * 100 < feature.percentage_of_time_value + end + + feature.enabled? end def self.migrate(feature) diff --git a/lib/gitlab/gitaly_client/commit.rb b/lib/gitlab/gitaly_client/commit.rb index 01cdc1ac14f..1601dc0800c 100644 --- a/lib/gitlab/gitaly_client/commit.rb +++ b/lib/gitlab/gitaly_client/commit.rb @@ -5,8 +5,6 @@ module Gitlab # See http://stackoverflow.com/a/40884093/1856239 and https://github.com/git/git/blob/3ad8b5bf26362ac67c9020bf8c30eee54a84f56d/cache.h#L1011-L1012 EMPTY_TREE_ID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'.freeze - attr_accessor :stub - def initialize(repository) @gitaly_repo = repository.gitaly_repository @repository = repository @@ -21,6 +19,7 @@ module Gitlab ) stub.commit_is_ancestor(request).value +<<<<<<< HEAD end class << self @@ -39,8 +38,43 @@ module Gitlab ) Gitlab::Git::DiffCollection.new(stub.commit_diff(request), options) +======= + end + + def diff_from_parent(commit, options = {}) + request_params = commit_diff_request_params(commit, options) + request_params[:ignore_whitespace_change] = options.fetch(:ignore_whitespace_change, false) + + response = diff_service_stub.commit_diff(Gitaly::CommitDiffRequest.new(request_params)) + Gitlab::Git::DiffCollection.new(GitalyClient::DiffStitcher.new(response), options) + end + + def commit_deltas(commit) + request_params = commit_diff_request_params(commit) + + response = diff_service_stub.commit_delta(Gitaly::CommitDeltaRequest.new(request_params)) + response.flat_map do |msg| + msg.deltas.map { |d| Gitlab::Git::Diff.new(d) } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end + + private + + def commit_diff_request_params(commit, options = {}) + parent_id = commit.parents[0]&.id || EMPTY_TREE_ID + + { + repository: @gitaly_repo, + left_commit_id: parent_id, + right_commit_id: commit.id, + paths: options.fetch(:paths, []) + } + end + + def diff_service_stub + GitalyClient.stub(:diff, @repository.storage) + end end end end diff --git a/lib/gitlab/gitaly_client/diff.rb b/lib/gitlab/gitaly_client/diff.rb new file mode 100644 index 00000000000..1e117b7e74a --- /dev/null +++ b/lib/gitlab/gitaly_client/diff.rb @@ -0,0 +1,21 @@ +module Gitlab + module GitalyClient + class Diff + FIELDS = %i(from_path to_path old_mode new_mode from_id to_id patch).freeze + + attr_accessor(*FIELDS) + + def initialize(params) + params.each do |key, val| + public_send(:"#{key}=", val) + end + end + + def ==(other) + FIELDS.all? do |field| + public_send(field) == other.public_send(field) + end + end + end + end +end diff --git a/lib/gitlab/gitaly_client/diff_stitcher.rb b/lib/gitlab/gitaly_client/diff_stitcher.rb new file mode 100644 index 00000000000..d84e8d752dc --- /dev/null +++ b/lib/gitlab/gitaly_client/diff_stitcher.rb @@ -0,0 +1,31 @@ +module Gitlab + module GitalyClient + class DiffStitcher + include Enumerable + + def initialize(rpc_response) + @rpc_response = rpc_response + end + + def each + current_diff = nil + + @rpc_response.each do |diff_msg| + if current_diff.nil? + diff_params = diff_msg.to_h.slice(*GitalyClient::Diff::FIELDS) + diff_params[:patch] = diff_msg.raw_patch_data + + current_diff = GitalyClient::Diff.new(diff_params) + else + current_diff.patch += diff_msg.raw_patch_data + end + + if diff_msg.end_of_patch + yield current_diff + current_diff = nil + end + end + end + end + end +end diff --git a/lib/gitlab/gitaly_client/ref.rb b/lib/gitlab/gitaly_client/ref.rb index 53c43e28df8..227fe45642e 100644 --- a/lib/gitlab/gitaly_client/ref.rb +++ b/lib/gitlab/gitaly_client/ref.rb @@ -44,6 +44,12 @@ module Gitlab branch_names.count end + def local_branches(sort_by: nil) + request = Gitaly::FindLocalBranchesRequest.new(repository: @gitaly_repo) + request.sort_by = sort_by_param(sort_by) if sort_by + consume_branches_response(stub.find_local_branches(request)) + end + private def consume_refs_response(response, prefix:) @@ -51,6 +57,16 @@ module Gitlab r.names.map { |name| name.sub(/\A#{Regexp.escape(prefix)}/, '') } end end + + def sort_by_param(sort_by) + enum_value = Gitaly::FindLocalBranchesRequest::SortBy.resolve(sort_by.upcase.to_sym) + raise ArgumentError, "Invalid sort_by key `#{sort_by}`" unless enum_value + enum_value + end + + def consume_branches_response(response) + response.flat_map { |r| r.branches } + end end end end diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb index 4acd297f5cb..86d055d3533 100644 --- a/lib/gitlab/gitaly_client/util.rb +++ b/lib/gitlab/gitaly_client/util.rb @@ -6,7 +6,7 @@ module Gitlab Gitaly::Repository.new( path: File.join(Gitlab.config.repositories.storages[repository_storage]['path'], relative_path), storage_name: repository_storage, - relative_path: relative_path, + relative_path: relative_path ) end end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 1e09cb5ca11..f18b7d4c313 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -1,3 +1,5 @@ +# rubocop:disable Metrics/AbcSize + module Gitlab module GonHelper def add_gon_variables @@ -13,11 +15,16 @@ module Gitlab gon.sentry_dsn = current_application_settings.clientside_sentry_dsn if current_application_settings.clientside_sentry_enabled gon.gitlab_url = Gitlab.config.gitlab.url gon.revision = Gitlab::REVISION +<<<<<<< HEAD +======= + gon.gitlab_logo = ActionController::Base.helpers.asset_path('gitlab_logo.png') +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 if current_user gon.current_user_id = current_user.id gon.current_username = current_user.username gon.current_user_fullname = current_user.name + gon.current_user_avatar_url = current_user.avatar_url end end end diff --git a/lib/gitlab/google_code_import/client.rb b/lib/gitlab/google_code_import/client.rb index 890bd9a3554..b1dbf554e41 100644 --- a/lib/gitlab/google_code_import/client.rb +++ b/lib/gitlab/google_code_import/client.rb @@ -14,7 +14,7 @@ module Gitlab end def valid? - raw_data.is_a?(Hash) && raw_data["kind"] == "projecthosting#user" && raw_data.has_key?("projects") + raw_data.is_a?(Hash) && raw_data["kind"] == "projecthosting#user" && raw_data.key?("projects") end def repos diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 1b43440673c..ab38c0c3e34 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -95,7 +95,7 @@ module Gitlab labels = import_issue_labels(raw_issue) assignee_id = nil - if raw_issue.has_key?("owner") + if raw_issue.key?("owner") username = user_map[raw_issue["owner"]["name"]] if username.start_with?("@") @@ -144,7 +144,7 @@ module Gitlab def import_issue_comments(issue, comments) Note.transaction do while raw_comment = comments.shift - next if raw_comment.has_key?("deletedBy") + next if raw_comment.key?("deletedBy") content = format_content(raw_comment["content"]) updates = format_updates(raw_comment["updates"]) @@ -235,15 +235,15 @@ module Gitlab def format_updates(raw_updates) updates = [] - if raw_updates.has_key?("status") + if raw_updates.key?("status") updates << "*Status: #{raw_updates["status"]}*" end - if raw_updates.has_key?("owner") + if raw_updates.key?("owner") updates << "*Owner: #{user_map[raw_updates["owner"]]}*" end - if raw_updates.has_key?("cc") + if raw_updates.key?("cc") cc = raw_updates["cc"].map do |l| deleted = l.start_with?("-") l = l[1..-1] if deleted @@ -255,7 +255,7 @@ module Gitlab updates << "*Cc: #{cc.join(", ")}*" end - if raw_updates.has_key?("labels") + if raw_updates.key?("labels") labels = raw_updates["labels"].map do |l| deleted = l.start_with?("-") l = l[1..-1] if deleted @@ -267,11 +267,11 @@ module Gitlab updates << "*Labels: #{labels.join(", ")}*" end - if raw_updates.has_key?("mergedInto") + if raw_updates.key?("mergedInto") updates << "*Merged into: ##{raw_updates["mergedInto"]}*" end - if raw_updates.has_key?("blockedOn") + if raw_updates.key?("blockedOn") blocked_ons = raw_updates["blockedOn"].map do |raw_blocked_on| format_blocking_updates(raw_blocked_on) end @@ -279,7 +279,7 @@ module Gitlab updates << "*Blocked on: #{blocked_ons.join(", ")}*" end - if raw_updates.has_key?("blocking") + if raw_updates.key?("blocking") blockings = raw_updates["blocking"].map do |raw_blocked_on| format_blocking_updates(raw_blocked_on) end diff --git a/lib/gitlab/health_checks/fs_shards_check.rb b/lib/gitlab/health_checks/fs_shards_check.rb index df962d203b7..e78b7f22e03 100644 --- a/lib/gitlab/health_checks/fs_shards_check.rb +++ b/lib/gitlab/health_checks/fs_shards_check.rb @@ -2,6 +2,9 @@ module Gitlab module HealthChecks class FsShardsCheck extend BaseAbstractCheck + RANDOM_STRING = SecureRandom.hex(1000).freeze + COMMAND_TIMEOUT = '1'.freeze + TIMEOUT_EXECUTABLE = 'timeout'.freeze class << self def readiness @@ -41,8 +44,6 @@ module Gitlab private - RANDOM_STRING = SecureRandom.hex(1000).freeze - def operation_metrics(ok_metric, latency_metric, operation, **labels) with_timing operation do |result, elapsed| [ @@ -63,8 +64,8 @@ module Gitlab @storage_paths ||= Gitlab.config.repositories.storages end - def with_timeout(args) - %w{timeout 1}.concat(args) + def exec_with_timeout(cmd_args, *args, &block) + Gitlab::Popen.popen([TIMEOUT_EXECUTABLE, COMMAND_TIMEOUT].concat(cmd_args), *args, &block) end def tmp_file_path(storage_name) @@ -78,7 +79,7 @@ module Gitlab def storage_stat_test(storage_name) stat_path = File.join(path(storage_name), '.') begin - _, status = Gitlab::Popen.popen(with_timeout(%W{ stat #{stat_path} })) + _, status = exec_with_timeout(%W{ stat #{stat_path} }) status == 0 rescue Errno::ENOENT File.exist?(stat_path) && File::Stat.new(stat_path).readable? @@ -86,7 +87,7 @@ module Gitlab end def storage_write_test(tmp_path) - _, status = Gitlab::Popen.popen(with_timeout(%W{ tee #{tmp_path} })) do |stdin| + _, status = exec_with_timeout(%W{ tee #{tmp_path} }) do |stdin| stdin.write(RANDOM_STRING) end status == 0 @@ -96,7 +97,7 @@ module Gitlab end def storage_read_test(tmp_path) - _, status = Gitlab::Popen.popen(with_timeout(%W{ diff #{tmp_path} - })) do |stdin| + _, status = exec_with_timeout(%W{ diff #{tmp_path} - }) do |stdin| stdin.write(RANDOM_STRING) end status == 0 @@ -106,7 +107,7 @@ module Gitlab end def delete_test_file(tmp_path) - _, status = Gitlab::Popen.popen(with_timeout(%W{ rm -f #{tmp_path} })) + _, status = exec_with_timeout(%W{ rm -f #{tmp_path} }) status == 0 rescue Errno::ENOENT File.delete(tmp_path) rescue Errno::ENOENT diff --git a/lib/gitlab/health_checks/prometheus_text_format.rb b/lib/gitlab/health_checks/prometheus_text_format.rb new file mode 100644 index 00000000000..b3c759b4730 --- /dev/null +++ b/lib/gitlab/health_checks/prometheus_text_format.rb @@ -0,0 +1,40 @@ +module Gitlab + module HealthChecks + class PrometheusTextFormat + def marshal(metrics) + "#{metrics_with_type_declarations(metrics).join("\n")}\n" + end + + private + + def metrics_with_type_declarations(metrics) + type_declaration_added = {} + + metrics.flat_map do |metric| + metric_lines = [] + + unless type_declaration_added.key?(metric.name) + type_declaration_added[metric.name] = true + metric_lines << metric_type_declaration(metric) + end + + metric_lines << metric_text(metric) + end + end + + def metric_type_declaration(metric) + "# TYPE #{metric.name} gauge" + end + + def metric_text(metric) + labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || '' + + if labels.empty? + "#{metric.name} #{metric.value}" + else + "#{metric.name}{#{labels}} #{metric.value}" + end + end + end + end +end diff --git a/lib/gitlab/highlight.rb b/lib/gitlab/highlight.rb index d787d5db4a0..83bc230df3e 100644 --- a/lib/gitlab/highlight.rb +++ b/lib/gitlab/highlight.rb @@ -13,6 +13,8 @@ module Gitlab highlight(file_name, blob.data, repository: repository).lines.map!(&:html_safe) end + attr_reader :blob_name + def initialize(blob_name, blob_content, repository: nil) @formatter = Rouge::Formatters::HTMLGitlab @repository = repository @@ -21,16 +23,9 @@ module Gitlab end def highlight(text, continue: true, plain: false) - if plain - hl_lexer = Rouge::Lexers::PlainText - continue = false - else - hl_lexer = self.lexer - end - - @formatter.format(hl_lexer.lex(text, continue: continue), tag: hl_lexer.tag).html_safe - rescue - @formatter.format(Rouge::Lexers::PlainText.lex(text)).html_safe + highlighted_text = highlight_text(text, continue: continue, plain: plain) + highlighted_text = link_dependencies(text, highlighted_text) if blob_name + highlighted_text end def lexer @@ -50,5 +45,27 @@ module Gitlab Rouge::Lexer.find_fancy(language_name) end + + def highlight_text(text, continue: true, plain: false) + if plain + highlight_plain(text) + else + highlight_rich(text, continue: continue) + end + end + + def highlight_plain(text) + @formatter.format(Rouge::Lexers::PlainText.lex(text)).html_safe + end + + def highlight_rich(text, continue: true) + @formatter.format(lexer.lex(text, continue: continue), tag: lexer.tag).html_safe + rescue + highlight_plain(text) + end + + def link_dependencies(text, highlighted_text) + Gitlab::DependencyLinker.link(blob_name, text, highlighted_text) + end end end diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb index 3411516319f..f7ac48f7dbd 100644 --- a/lib/gitlab/i18n.rb +++ b/lib/gitlab/i18n.rb @@ -5,22 +5,46 @@ module Gitlab AVAILABLE_LANGUAGES = { 'en' => 'English', 'es' => 'Español', - 'de' => 'Deutsch' + 'de' => 'Deutsch', + 'zh_CN' => '简体中文', + 'zh_HK' => '繁體中文(香港)', + 'zh_TW' => '繁體中文(臺灣)' }.freeze def available_locales AVAILABLE_LANGUAGES.keys end - def set_locale(current_user) - requested_locale = current_user&.preferred_language || ::I18n.default_locale - locale = FastGettext.set_locale(requested_locale) - ::I18n.locale = locale + def locale + FastGettext.locale end - def reset_locale + def locale=(locale_string) + requested_locale = locale_string || ::I18n.default_locale + new_locale = FastGettext.set_locale(requested_locale) + ::I18n.locale = new_locale + end + + def use_default_locale FastGettext.set_locale(::I18n.default_locale) ::I18n.locale = ::I18n.default_locale end + + def with_locale(locale_string) + original_locale = locale + + self.locale = locale_string + yield + ensure + self.locale = original_locale + end + + def with_user_locale(user, &block) + with_locale(user&.preferred_language, &block) + end + + def with_default_locale(&block) + with_locale(::I18n.default_locale, &block) + end end end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index d0f3cf2b514..ff2b1d08c3c 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -38,6 +38,7 @@ project_tree: - notes: - :author - :events + - :stages - :statuses - :triggers - :pipeline_schedules diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 19e23a4715f..695852526cb 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -3,6 +3,7 @@ module Gitlab class RelationFactory OVERRIDES = { snippets: :project_snippets, pipelines: 'Ci::Pipeline', + stages: 'Ci::Stage', statuses: 'commit_status', triggers: 'Ci::Trigger', pipeline_schedules: 'Ci::PipelineSchedule', diff --git a/lib/gitlab/kubernetes.rb b/lib/gitlab/kubernetes.rb index 3a7af363548..4a6091488c8 100644 --- a/lib/gitlab/kubernetes.rb +++ b/lib/gitlab/kubernetes.rb @@ -38,7 +38,7 @@ module Gitlab url: container_exec_url(api_url, namespace, pod_name, container["name"]), subprotocols: ['channel.k8s.io'], headers: Hash.new { |h, k| h[k] = [] }, - created_at: created_at, + created_at: created_at } end end @@ -64,7 +64,7 @@ module Gitlab tty: true, stdin: true, stdout: true, - stderr: true, + stderr: true }.to_query + '&' + EXEC_COMMAND case url.scheme diff --git a/lib/gitlab/ldap/config.rb b/lib/gitlab/ldap/config.rb index 46deea3cc9f..6fdf68641e2 100644 --- a/lib/gitlab/ldap/config.rb +++ b/lib/gitlab/ldap/config.rb @@ -39,7 +39,7 @@ module Gitlab def adapter_options opts = base_options.merge( - encryption: encryption, + encryption: encryption ) opts.merge!(auth_options) if has_auth? diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb index 2d5e47a6f3b..5e299e26c54 100644 --- a/lib/gitlab/ldap/user.rb +++ b/lib/gitlab/ldap/user.rb @@ -41,11 +41,6 @@ module Gitlab def update_user_attributes if persisted? - if auth_hash.has_email? - gl_user.skip_reconfirmation! - gl_user.email = auth_hash.email - end - # find_or_initialize_by doesn't update `gl_user.identities`, and isn't autosaved. identity = gl_user.identities.find { |identity| identity.provider == auth_hash.provider } identity ||= gl_user.identities.build(provider: auth_hash.provider) @@ -55,10 +50,6 @@ module Gitlab # For an existing identity with no change in DN, this line changes nothing. identity.extern_uid = auth_hash.uid end - - gl_user.ldap_email = auth_hash.has_email? - - gl_user end def changed? @@ -69,6 +60,10 @@ module Gitlab ldap_config.block_auto_created_users end + def sync_email_from_provider? + true + end + def allowed? Gitlab::LDAP::Access.allowed?(gl_user) end diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb index c6dfa4ad9bd..4779755bb22 100644 --- a/lib/gitlab/metrics.rb +++ b/lib/gitlab/metrics.rb @@ -1,158 +1,10 @@ module Gitlab module Metrics - extend Gitlab::CurrentSettings - - RAILS_ROOT = Rails.root.to_s - METRICS_ROOT = Rails.root.join('lib', 'gitlab', 'metrics').to_s - PATH_REGEX = /^#{RAILS_ROOT}\/?/ - - def self.settings - @settings ||= { - enabled: current_application_settings[:metrics_enabled], - pool_size: current_application_settings[:metrics_pool_size], - timeout: current_application_settings[:metrics_timeout], - method_call_threshold: current_application_settings[:metrics_method_call_threshold], - host: current_application_settings[:metrics_host], - port: current_application_settings[:metrics_port], - sample_interval: current_application_settings[:metrics_sample_interval] || 15, - packet_size: current_application_settings[:metrics_packet_size] || 1 - } - end + extend Gitlab::Metrics::InfluxDb + extend Gitlab::Metrics::Prometheus def self.enabled? - settings[:enabled] || false - end - - def self.mri? - RUBY_ENGINE == 'ruby' - end - - def self.method_call_threshold - # This is memoized since this method is called for every instrumented - # method. Loading data from an external cache on every method call slows - # things down too much. - @method_call_threshold ||= settings[:method_call_threshold] - end - - def self.pool - @pool - end - - def self.submit_metrics(metrics) - prepared = prepare_metrics(metrics) - - pool.with do |connection| - prepared.each_slice(settings[:packet_size]) do |slice| - begin - connection.write_points(slice) - rescue StandardError - end - end - end - end - - def self.prepare_metrics(metrics) - metrics.map do |hash| - new_hash = hash.symbolize_keys - - new_hash[:tags].each do |key, value| - if value.blank? - new_hash[:tags].delete(key) - else - new_hash[:tags][key] = escape_value(value) - end - end - - new_hash - end - end - - def self.escape_value(value) - value.to_s.gsub('=', '\\=') - end - - # Measures the execution time of a block. - # - # Example: - # - # Gitlab::Metrics.measure(:find_by_username_duration) do - # User.find_by_username(some_username) - # end - # - # name - The name of the field to store the execution time in. - # - # Returns the value yielded by the supplied block. - def self.measure(name) - trans = current_transaction - - return yield unless trans - - real_start = Time.now.to_f - cpu_start = System.cpu_time - - retval = yield - - cpu_stop = System.cpu_time - real_stop = Time.now.to_f - - real_time = (real_stop - real_start) * 1000.0 - cpu_time = cpu_stop - cpu_start - - trans.increment("#{name}_real_time", real_time) - trans.increment("#{name}_cpu_time", cpu_time) - trans.increment("#{name}_call_count", 1) - - retval - end - - # Adds a tag to the current transaction (if any) - # - # name - The name of the tag to add. - # value - The value of the tag. - def self.tag_transaction(name, value) - trans = current_transaction - - trans&.add_tag(name, value) - end - - # Sets the action of the current transaction (if any) - # - # action - The name of the action. - def self.action=(action) - trans = current_transaction - - trans&.action = action - 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) - end - - # Returns the prefix to use for the name of a series. - def self.series_prefix - @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_' - end - - # Allow access from other metrics related middlewares - def self.current_transaction - Transaction.current - end - - # When enabled this should be set before being used as the usual pattern - # "@foo ||= bar" is _not_ thread-safe. - if enabled? - @pool = ConnectionPool.new(size: settings[:pool_size], timeout: settings[:timeout]) do - host = settings[:host] - port = settings[:port] - - InfluxDB::Client. - new(udp: { host: host, port: port }) - end + influx_metrics_enabled? || prometheus_metrics_enabled? end end end diff --git a/lib/gitlab/metrics/influx_db.rb b/lib/gitlab/metrics/influx_db.rb new file mode 100644 index 00000000000..3a39791edbf --- /dev/null +++ b/lib/gitlab/metrics/influx_db.rb @@ -0,0 +1,170 @@ +module Gitlab + module Metrics + module InfluxDb + extend Gitlab::CurrentSettings + extend self + + MUTEX = Mutex.new + private_constant :MUTEX + + def influx_metrics_enabled? + settings[:enabled] || false + end + + RAILS_ROOT = Rails.root.to_s + METRICS_ROOT = Rails.root.join('lib', 'gitlab', 'metrics').to_s + PATH_REGEX = /^#{RAILS_ROOT}\/?/ + + def settings + @settings ||= { + enabled: current_application_settings[:metrics_enabled], + pool_size: current_application_settings[:metrics_pool_size], + timeout: current_application_settings[:metrics_timeout], + method_call_threshold: current_application_settings[:metrics_method_call_threshold], + host: current_application_settings[:metrics_host], + port: current_application_settings[:metrics_port], + sample_interval: current_application_settings[:metrics_sample_interval] || 15, + packet_size: current_application_settings[:metrics_packet_size] || 1 + } + end + + def mri? + RUBY_ENGINE == 'ruby' + end + + def method_call_threshold + # This is memoized since this method is called for every instrumented + # method. Loading data from an external cache on every method call slows + # things down too much. + @method_call_threshold ||= settings[:method_call_threshold] + end + + def submit_metrics(metrics) + prepared = prepare_metrics(metrics) + + pool&.with do |connection| + prepared.each_slice(settings[:packet_size]) do |slice| + begin + connection.write_points(slice) + rescue StandardError + end + end + end + rescue Errno::EADDRNOTAVAIL, SocketError => ex + Gitlab::EnvironmentLogger.error('Cannot resolve InfluxDB address. GitLab Performance Monitoring will not work.') + Gitlab::EnvironmentLogger.error(ex) + end + + def prepare_metrics(metrics) + metrics.map do |hash| + new_hash = hash.symbolize_keys + + new_hash[:tags].each do |key, value| + if value.blank? + new_hash[:tags].delete(key) + else + new_hash[:tags][key] = escape_value(value) + end + end + + new_hash + end + end + + def escape_value(value) + value.to_s.gsub('=', '\\=') + end + + # Measures the execution time of a block. + # + # Example: + # + # Gitlab::Metrics.measure(:find_by_username_duration) do + # User.find_by_username(some_username) + # end + # + # name - The name of the field to store the execution time in. + # + # Returns the value yielded by the supplied block. + def measure(name) + trans = current_transaction + + return yield unless trans + + real_start = Time.now.to_f + cpu_start = System.cpu_time + + retval = yield + + cpu_stop = System.cpu_time + real_stop = Time.now.to_f + + real_time = (real_stop - real_start) * 1000.0 + cpu_time = cpu_stop - cpu_start + + trans.increment("#{name}_real_time", real_time) + trans.increment("#{name}_cpu_time", cpu_time) + trans.increment("#{name}_call_count", 1) + + retval + end + + # Adds a tag to the current transaction (if any) + # + # name - The name of the tag to add. + # value - The value of the tag. + def tag_transaction(name, value) + trans = current_transaction + + trans&.add_tag(name, value) + end + + # Sets the action of the current transaction (if any) + # + # action - The name of the action. + def action=(action) + trans = current_transaction + + trans&.action = action + end + + # Tracks an event. + # + # See `Gitlab::Metrics::Transaction#add_event` for more details. + def add_event(*args) + trans = current_transaction + + trans&.add_event(*args) + end + + # Returns the prefix to use for the name of a series. + def series_prefix + @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_' + end + + # Allow access from other metrics related middlewares + def current_transaction + Transaction.current + end + + # When enabled this should be set before being used as the usual pattern + # "@foo ||= bar" is _not_ thread-safe. + def pool + if influx_metrics_enabled? + if @pool.nil? + MUTEX.synchronize do + @pool ||= ConnectionPool.new(size: settings[:pool_size], timeout: settings[:timeout]) do + host = settings[:host] + port = settings[:port] + + InfluxDB::Client. + new(udp: { host: host, port: port }) + end + end + end + @pool + end + end + end + end +end diff --git a/lib/gitlab/metrics/null_metric.rb b/lib/gitlab/metrics/null_metric.rb new file mode 100644 index 00000000000..3b5a2907195 --- /dev/null +++ b/lib/gitlab/metrics/null_metric.rb @@ -0,0 +1,10 @@ +module Gitlab + module Metrics + # Mocks ::Prometheus::Client::Metric and all derived metrics + class NullMetric + def method_missing(name, *args, &block) + nil + end + end + end +end diff --git a/lib/gitlab/metrics/prometheus.rb b/lib/gitlab/metrics/prometheus.rb new file mode 100644 index 00000000000..60686509332 --- /dev/null +++ b/lib/gitlab/metrics/prometheus.rb @@ -0,0 +1,41 @@ +require 'prometheus/client' + +module Gitlab + module Metrics + module Prometheus + include Gitlab::CurrentSettings + + def prometheus_metrics_enabled? + @prometheus_metrics_enabled ||= current_application_settings[:prometheus_metrics_enabled] || false + end + + def registry + @registry ||= ::Prometheus::Client.registry + end + + def counter(name, docstring, base_labels = {}) + provide_metric(name) || registry.counter(name, docstring, base_labels) + end + + def summary(name, docstring, base_labels = {}) + provide_metric(name) || registry.summary(name, docstring, base_labels) + end + + def gauge(name, docstring, base_labels = {}) + provide_metric(name) || registry.gauge(name, docstring, base_labels) + end + + def histogram(name, docstring, base_labels = {}, buckets = ::Prometheus::Client::Histogram::DEFAULT_BUCKETS) + provide_metric(name) || registry.histogram(name, docstring, base_labels, buckets) + end + + def provide_metric(name) + if prometheus_metrics_enabled? + registry.get(name) + else + NullMetric.new + end + end + end + end +end diff --git a/lib/gitlab/o_auth/provider.rb b/lib/gitlab/o_auth/provider.rb index 9ad7a38d505..ac9d66c836d 100644 --- a/lib/gitlab/o_auth/provider.rb +++ b/lib/gitlab/o_auth/provider.rb @@ -22,7 +22,11 @@ module Gitlab def self.config_for(name) name = name.to_s if ldap_provider?(name) - Gitlab::LDAP::Config.new(name).options + if Gitlab::LDAP::Config.valid_provider?(name) + Gitlab::LDAP::Config.new(name).options + else + nil + end else Gitlab.config.omniauth.providers.find { |provider| provider.name == name } end diff --git a/lib/gitlab/o_auth/user.rb b/lib/gitlab/o_auth/user.rb index afd24b4dcc5..7307f8c2c87 100644 --- a/lib/gitlab/o_auth/user.rb +++ b/lib/gitlab/o_auth/user.rb @@ -12,6 +12,7 @@ module Gitlab def initialize(auth_hash) self.auth_hash = auth_hash + update_email end def persisted? @@ -174,6 +175,22 @@ module Gitlab } end + def sync_email_from_provider? + auth_hash.provider.to_s == Gitlab.config.omniauth.sync_email_from_provider.to_s + end + + def update_email + if auth_hash.has_email? && sync_email_from_provider? + if persisted? + gl_user.skip_reconfirmation! + gl_user.email = auth_hash.email + end + + gl_user.external_email = true + gl_user.email_provider = auth_hash.provider + end + end + def log Gitlab::AppLogger end diff --git a/lib/gitlab/otp_key_rotator.rb b/lib/gitlab/otp_key_rotator.rb new file mode 100644 index 00000000000..0d541935bc6 --- /dev/null +++ b/lib/gitlab/otp_key_rotator.rb @@ -0,0 +1,87 @@ +module Gitlab + # The +otp_key_base+ param is used to encrypt the User#otp_secret attribute. + # + # When +otp_key_base+ is changed, it invalidates the current encrypted values + # of User#otp_secret. This class can be used to decrypt all the values with + # the old key, encrypt them with the new key, and and update the database + # with the new values. + # + # For persistence between runs, a CSV file is used with the following columns: + # + # user_id, old_value, new_value + # + # Only the encrypted values are stored in this file. + # + # As users may have their 2FA settings changed at any time, this is only + # guaranteed to be safe if run offline. + class OtpKeyRotator + HEADERS = %w[user_id old_value new_value].freeze + + attr_reader :filename + + # Create a new rotator. +filename+ is used to store values by +calculate!+, + # and to update the database with new and old values in +apply!+ and + # +rollback!+, respectively. + def initialize(filename) + @filename = filename + end + + def rotate!(old_key:, new_key:) + old_key ||= Gitlab::Application.secrets.otp_key_base + + raise ArgumentError.new("Old key is the same as the new key") if old_key == new_key + raise ArgumentError.new("New key is too short! Must be 256 bits") if new_key.size < 64 + + write_csv do |csv| + ActiveRecord::Base.transaction do + User.with_two_factor.in_batches do |relation| + rows = relation.pluck(:id, :encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt) + rows.each do |row| + user = %i[id ciphertext iv salt].zip(row).to_h + new_value = reencrypt(user, old_key, new_key) + + User.where(id: user[:id]).update_all(encrypted_otp_secret: new_value) + csv << [user[:id], user[:ciphertext], new_value] + end + end + end + end + end + + def rollback! + ActiveRecord::Base.transaction do + CSV.foreach(filename, headers: HEADERS, return_headers: false) do |row| + User.where(id: row['user_id']).update_all(encrypted_otp_secret: row['old_value']) + end + end + end + + private + + attr_reader :old_key, :new_key + + def otp_secret_settings + @otp_secret_settings ||= User.encrypted_attributes[:otp_secret] + end + + def reencrypt(user, old_key, new_key) + original = user[:ciphertext].unpack("m").join + opts = { + iv: user[:iv].unpack("m").join, + salt: user[:salt].unpack("m").join, + algorithm: otp_secret_settings[:algorithm], + insecure_mode: otp_secret_settings[:insecure_mode] + } + + decrypted = Encryptor.decrypt(original, opts.merge(key: old_key)) + encrypted = Encryptor.encrypt(decrypted, opts.merge(key: new_key)) + [encrypted].pack("m") + end + + def write_csv(&blk) + File.open(filename, "w") do |file| + yield CSV.new(file, headers: HEADERS, write_headers: false) + end + end + end +end diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb new file mode 100644 index 00000000000..9ff6829cd49 --- /dev/null +++ b/lib/gitlab/path_regex.rb @@ -0,0 +1,265 @@ +module Gitlab + module PathRegex + extend self + + # All routes that appear on the top level must be listed here. + # This will make sure that groups cannot be created with these names + # as these routes would be masked by the paths already in place. + # + # Example: + # /api/api-project + # + # the path `api` shouldn't be allowed because it would be masked by `api/*` + # + TOP_LEVEL_ROUTES = %w[ + - + .well-known + abuse_reports + admin + all + api + assets + autocomplete + ci + dashboard + explore + files + groups + health_check + help + hooks + import + invites + issues + jwt + koding + member + merge_requests + new + notes + notification_settings + oauth + profile + projects + public + repository + robots.txt + s + search + sent_notifications + services + snippets + teams + u + unicorn_test + unsubscribes + uploads + users + ].freeze + + # This list should contain all words following `/*namespace_id/:project_id` in + # routes that contain a second wildcard. + # + # Example: + # /*namespace_id/:project_id/badges/*ref/build + # + # If `badges` was allowed as a project/group name, we would not be able to access the + # `badges` route for those projects: + # + # Consider a namespace with path `foo/bar` and a project called `badges`. + # The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg` + # + # When accessing this path the route would be matched to the `badges` path + # with the following params: + # - namespace_id: `foo` + # - project_id: `bar` + # - ref: `badges/master` + # + # Failing to find the project, this would result in a 404. + # + # By rejecting `badges` the router can _count_ on the fact that `badges` will + # be preceded by the `namespace/project`. + PROJECT_WILDCARD_ROUTES = %w[ + - + badges + blame + blob + builds + commits + create + create_dir + edit + environments/folders + files + find_file + gitlab-lfs/objects + info/lfs/objects + new + preview + raw + refs + tree + update + wikis + ].freeze + + # These are all the paths that follow `/groups/*id/ or `/groups/*group_id` + # We need to reject these because we have a `/groups/*id` page that is the same + # as the `/*id`. + # + # If we would allow a subgroup to be created with the name `activity` then + # this group would not be accessible through `/groups/parent/activity` since + # this would map to the activity-page of its parent. + GROUP_ROUTES = %w[ + activity + analytics + audit_events + avatar + edit + group_members + hooks + issues + labels + ldap + ldap_group_links + merge_requests + milestones + notification_setting + pipeline_quota + projects + subgroups + ].freeze + + ILLEGAL_PROJECT_PATH_WORDS = PROJECT_WILDCARD_ROUTES + ILLEGAL_GROUP_PATH_WORDS = (PROJECT_WILDCARD_ROUTES | GROUP_ROUTES).freeze + + # The namespace regex is used in JavaScript to validate usernames in the "Register" form. However, Javascript + # does not support the negative lookbehind assertion (?<!) that disallows usernames ending in `.git` and `.atom`. + # Since this is a non-trivial problem to solve in Javascript (heavily complicate the regex, modify view code to + # allow non-regex validations, etc), `NAMESPACE_FORMAT_REGEX_JS` serves as a Javascript-compatible version of + # `NAMESPACE_FORMAT_REGEX`, with the negative lookbehind assertion removed. This means that the client-side validation + # will pass for usernames ending in `.atom` and `.git`, but will be caught by the server-side validation. + PATH_REGEX_STR = '[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*'.freeze + NAMESPACE_FORMAT_REGEX_JS = PATH_REGEX_STR + '[a-zA-Z0-9_\-]|[a-zA-Z0-9_]'.freeze + + NO_SUFFIX_REGEX = /(?<!\.git|\.atom)/.freeze + NAMESPACE_FORMAT_REGEX = /(?:#{NAMESPACE_FORMAT_REGEX_JS})#{NO_SUFFIX_REGEX}/.freeze + PROJECT_PATH_FORMAT_REGEX = /(?:#{PATH_REGEX_STR})#{NO_SUFFIX_REGEX}/.freeze + FULL_NAMESPACE_FORMAT_REGEX = %r{(#{NAMESPACE_FORMAT_REGEX}/)*#{NAMESPACE_FORMAT_REGEX}}.freeze + + def root_namespace_route_regex + @root_namespace_route_regex ||= begin + illegal_words = Regexp.new(Regexp.union(TOP_LEVEL_ROUTES).source, Regexp::IGNORECASE) + + single_line_regexp %r{ + (?!(#{illegal_words})/) + #{NAMESPACE_FORMAT_REGEX} + }x + end + end + + def full_namespace_route_regex + @full_namespace_route_regex ||= begin + illegal_words = Regexp.new(Regexp.union(ILLEGAL_GROUP_PATH_WORDS).source, Regexp::IGNORECASE) + + single_line_regexp %r{ + #{root_namespace_route_regex} + (?: + / + (?!#{illegal_words}/) + #{NAMESPACE_FORMAT_REGEX} + )* + }x + end + end + + def project_route_regex + @project_route_regex ||= begin + illegal_words = Regexp.new(Regexp.union(ILLEGAL_PROJECT_PATH_WORDS).source, Regexp::IGNORECASE) + + single_line_regexp %r{ + (?!(#{illegal_words})/) + #{PROJECT_PATH_FORMAT_REGEX} + }x + end + end + + def project_git_route_regex + @project_git_route_regex ||= /#{project_route_regex}\.git/.freeze + end + + def root_namespace_path_regex + @root_namespace_path_regex ||= %r{\A#{root_namespace_route_regex}/\z} + end + + def full_namespace_path_regex + @full_namespace_path_regex ||= %r{\A#{full_namespace_route_regex}/\z} + end + + def project_path_regex + @project_path_regex ||= %r{\A#{project_route_regex}/\z} + end + + def full_project_path_regex + @full_project_path_regex ||= %r{\A#{full_namespace_route_regex}/#{project_route_regex}/\z} + end + + def full_namespace_format_regex + @namespace_format_regex ||= /A#{FULL_NAMESPACE_FORMAT_REGEX}\z/.freeze + end + + def namespace_format_regex + @namespace_format_regex ||= /\A#{NAMESPACE_FORMAT_REGEX}\z/.freeze + end + + def namespace_format_message + "can contain only letters, digits, '_', '-' and '.'. " \ + "Cannot start with '-' or end in '.', '.git' or '.atom'." \ + end + + def project_path_format_regex + @project_path_format_regex ||= /\A#{PROJECT_PATH_FORMAT_REGEX}\z/.freeze + end + + def project_path_format_message + "can contain only letters, digits, '_', '-' and '.'. " \ + "Cannot start with '-', end in '.git' or end in '.atom'" \ + end + + def archive_formats_regex + # |zip|tar| tar.gz | tar.bz2 | + @archive_formats_regex ||= /(zip|tar|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/.freeze + end + + def git_reference_regex + # Valid git ref regex, see: + # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html + + @git_reference_regex ||= single_line_regexp %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 + + private + + def single_line_regexp(regex) + # Turns a multiline extended regexp into a single line one, + # beacuse `rake routes` breaks on multiline regexes. + Regexp.new(regex.source.gsub(/\(\?#.+?\)/, '').gsub(/\s*/, ''), regex.options ^ Regexp::EXTENDED).freeze + end + end +end diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 47cfe412715..ec26ddf2e48 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -83,6 +83,7 @@ module Gitlab def blobs return [] unless Ability.allowed?(@current_user, :download_code, @project) +<<<<<<< HEAD @blobs ||= begin blobs = project.repository.search_files_by_content(query, repository_ref).first(100) @@ -98,9 +99,10 @@ module Gitlab project.repository.search_files_by_name(query, repository_ref).first(100).each do |filename| results << [filename, nil] unless found_file_names.include?(filename) end +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - results.sort_by(&:first) - end + @blobs ||= Gitlab::FileFinder.new(project, repository_ref).find(query) end def wiki_blobs diff --git a/lib/gitlab/prometheus/queries/base_query.rb b/lib/gitlab/prometheus/queries/base_query.rb new file mode 100644 index 00000000000..2a2eb4ae57f --- /dev/null +++ b/lib/gitlab/prometheus/queries/base_query.rb @@ -0,0 +1,26 @@ +module Gitlab + module Prometheus + module Queries + class BaseQuery + attr_accessor :client + delegate :query_range, :query, to: :client, prefix: true + + def raw_memory_usage_query(environment_slug) + %{avg(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / 2^20} + end + + def raw_cpu_usage_query(environment_slug) + %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100} + end + + def initialize(client) + @client = client + end + + def query(*args) + raise NotImplementedError + end + end + end + end +end diff --git a/lib/gitlab/prometheus/queries/deployment_query.rb b/lib/gitlab/prometheus/queries/deployment_query.rb new file mode 100644 index 00000000000..2cc08731f8d --- /dev/null +++ b/lib/gitlab/prometheus/queries/deployment_query.rb @@ -0,0 +1,26 @@ +module Gitlab::Prometheus::Queries + class DeploymentQuery < BaseQuery + def query(deployment_id) + deployment = Deployment.find_by(id: deployment_id) + environment_slug = deployment.environment.slug + + memory_query = raw_memory_usage_query(environment_slug) + memory_avg_query = %{avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}[30m]))} + cpu_query = raw_cpu_usage_query(environment_slug) + cpu_avg_query = %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[30m])) * 100} + + timeframe_start = (deployment.created_at - 30.minutes).to_f + timeframe_end = (deployment.created_at + 30.minutes).to_f + + { + memory_values: client_query_range(memory_query, start: timeframe_start, stop: timeframe_end), + memory_before: client_query(memory_avg_query, time: deployment.created_at.to_f), + memory_after: client_query(memory_avg_query, time: timeframe_end), + + cpu_values: client_query_range(cpu_query, start: timeframe_start, stop: timeframe_end), + cpu_before: client_query(cpu_avg_query, time: deployment.created_at.to_f), + cpu_after: client_query(cpu_avg_query, time: timeframe_end) + } + end + end +end diff --git a/lib/gitlab/prometheus/queries/environment_query.rb b/lib/gitlab/prometheus/queries/environment_query.rb new file mode 100644 index 00000000000..01d756d7284 --- /dev/null +++ b/lib/gitlab/prometheus/queries/environment_query.rb @@ -0,0 +1,20 @@ +module Gitlab::Prometheus::Queries + class EnvironmentQuery < BaseQuery + def query(environment_id) + environment = Environment.find_by(id: environment_id) + environment_slug = environment.slug + timeframe_start = 8.hours.ago.to_f + timeframe_end = Time.now.to_f + + memory_query = raw_memory_usage_query(environment_slug) + cpu_query = raw_cpu_usage_query(environment_slug) + + { + memory_values: client_query_range(memory_query, start: timeframe_start, stop: timeframe_end), + memory_current: client_query(memory_query, time: timeframe_end), + cpu_values: client_query_range(cpu_query, start: timeframe_start, stop: timeframe_end), + cpu_current: client_query(cpu_query, time: timeframe_end) + } + end + end +end diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus_client.rb index 37125980b1c..89dd468edf4 100644 --- a/lib/gitlab/prometheus.rb +++ b/lib/gitlab/prometheus_client.rb @@ -2,7 +2,7 @@ module Gitlab PrometheusError = Class.new(StandardError) # Helper methods to interact with Prometheus network services & resources - class Prometheus + class PrometheusClient attr_reader :api_url def initialize(api_url:) @@ -15,7 +15,11 @@ module Gitlab def query(query, time: Time.now) get_result('vector') do +<<<<<<< HEAD:lib/gitlab/prometheus.rb json_api_get('query', query: query, time: time.utc.to_f) +======= + json_api_get('query', query: query, time: time.to_f) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259:lib/gitlab/prometheus_client.rb end end diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 34b6921d606..4d5dd2eae62 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -2,6 +2,7 @@ module Gitlab module Regex extend self +<<<<<<< HEAD # All routes that appear on the top level must be listed here. # This will make sure that groups cannot be created with these names # as these routes would be masked by the paths already in place. @@ -200,6 +201,8 @@ module Gitlab "Cannot start with '-' or end in '.', '.git' or '.atom'." \ end +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 def namespace_name_regex @namespace_name_regex ||= /\A[\p{Alnum}\p{Pd}_\. ]*\z/.freeze end @@ -217,6 +220,7 @@ module Gitlab "It must start with letter, digit, emoji or '_'." end +<<<<<<< HEAD def project_path_regex @project_path_regex ||= %r{\A#{project_route_regex}/\z} end @@ -245,6 +249,8 @@ module Gitlab "Cannot start with '-', end in '.git' or end in '.atom'" \ end +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 def file_name_regex @file_name_regex ||= /\A[[[:alnum:]]_\-\.\@\+]*\z/.freeze end @@ -253,6 +259,7 @@ module Gitlab "can contain only letters, digits, '_', '-', '@', '+' and '.'." end +<<<<<<< HEAD def archive_formats_regex # |zip|tar| tar.gz | tar.bz2 | @archive_formats_regex ||= /(zip|tar|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/.freeze @@ -281,8 +288,10 @@ module Gitlab }x end +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 def container_registry_reference_regex - git_reference_regex + Gitlab::PathRegex.git_reference_regex end ## diff --git a/lib/gitlab/route_map.rb b/lib/gitlab/route_map.rb index 36791fae60f..877aa6e6a28 100644 --- a/lib/gitlab/route_map.rb +++ b/lib/gitlab/route_map.rb @@ -25,8 +25,8 @@ module Gitlab def parse_entry(entry) raise FormatError, 'Route map entry is not a hash' unless entry.is_a?(Hash) - raise FormatError, 'Route map entry does not have a source key' unless entry.has_key?('source') - raise FormatError, 'Route map entry does not have a public key' unless entry.has_key?('public') + raise FormatError, 'Route map entry does not have a source key' unless entry.key?('source') + raise FormatError, 'Route map entry does not have a public key' unless entry.key?('public') source_pattern = entry['source'] public_path = entry['public'] diff --git a/lib/gitlab/routes/legacy_builds.rb b/lib/gitlab/routes/legacy_builds.rb new file mode 100644 index 00000000000..36d1a8a6f64 --- /dev/null +++ b/lib/gitlab/routes/legacy_builds.rb @@ -0,0 +1,36 @@ +module Gitlab + module Routes + class LegacyBuilds + def initialize(map) + @map = map + end + + def draw + @map.instance_eval do + resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do + collection do + resources :artifacts, only: [], controller: 'build_artifacts' do + collection do + get :latest_succeeded, + path: '*ref_name_and_path', + format: false + end + end + end + + member do + get :raw + end + + resource :artifacts, only: [], controller: 'build_artifacts' do + get :download + get :browse, path: 'browse(/*path)', format: false + get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false + end + end + end + end + end + end +end diff --git a/lib/gitlab/sentry.rb b/lib/gitlab/sentry.rb index 117fc508135..2442c2ded3b 100644 --- a/lib/gitlab/sentry.rb +++ b/lib/gitlab/sentry.rb @@ -11,7 +11,7 @@ module Gitlab Raven.user_context( id: current_user.id, email: current_user.email, - username: current_user.username, + username: current_user.username ) end end diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb index 12a385f90fd..caab8856014 100644 --- a/lib/gitlab/slash_commands/command_definition.rb +++ b/lib/gitlab/slash_commands/command_definition.rb @@ -48,17 +48,23 @@ module Gitlab end def to_h(opts) + context = OpenStruct.new(opts) + desc = description if desc.respond_to?(:call) - context = OpenStruct.new(opts) desc = context.instance_exec(&desc) rescue '' end + prms = params + if prms.respond_to?(:call) + prms = Array(context.instance_exec(&prms)) rescue params + end + { name: name, aliases: aliases, description: desc, - params: params + params: prms } end diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb index 614bafbe1b2..1b5b4566d81 100644 --- a/lib/gitlab/slash_commands/dsl.rb +++ b/lib/gitlab/slash_commands/dsl.rb @@ -40,8 +40,8 @@ module Gitlab # command :command_key do |arguments| # # Awesome code block # end - def params(*params) - @params = params + def params(*params, &block) + @params = block_given? ? block : params end # Allows to give an explanation of what the command will do when diff --git a/lib/gitlab/string_range_marker.rb b/lib/gitlab/string_range_marker.rb new file mode 100644 index 00000000000..94fba0a221a --- /dev/null +++ b/lib/gitlab/string_range_marker.rb @@ -0,0 +1,102 @@ +module Gitlab + class StringRangeMarker + attr_accessor :raw_line, :rich_line + + def initialize(raw_line, rich_line = raw_line) + @raw_line = raw_line + @rich_line = ERB::Util.html_escape(rich_line) + end + + def mark(marker_ranges) + return rich_line unless marker_ranges + + rich_marker_ranges = [] + marker_ranges.each do |range| + # Map the inline-diff range based on the raw line to character positions in the rich line + rich_positions = position_mapping[range].flatten + # Turn the array of character positions into ranges + rich_marker_ranges.concat(collapse_ranges(rich_positions)) + end + + offset = 0 + # Mark each range + rich_marker_ranges.each_with_index do |range, i| + offset_range = (range.begin + offset)..(range.end + offset) + original_text = rich_line[offset_range] + + text = yield(original_text, left: i == 0, right: i == rich_marker_ranges.length - 1) + + rich_line[offset_range] = text + + offset += text.length - original_text.length + end + + rich_line.html_safe + end + + private + + # Mapping of character positions in the raw line, to the rich (highlighted) line + def position_mapping + @position_mapping ||= begin + mapping = [] + rich_pos = 0 + (0..raw_line.length).each do |raw_pos| + rich_char = rich_line[rich_pos] + + # The raw and rich lines are the same except for HTML tags, + # so skip over any `<...>` segment + while rich_char == '<' + until rich_char == '>' + rich_pos += 1 + rich_char = rich_line[rich_pos] + end + + rich_pos += 1 + rich_char = rich_line[rich_pos] + end + + # multi-char HTML entities in the rich line correspond to a single character in the raw line + if rich_char == '&' + multichar_mapping = [rich_pos] + until rich_char == ';' + rich_pos += 1 + multichar_mapping << rich_pos + rich_char = rich_line[rich_pos] + end + + mapping[raw_pos] = multichar_mapping + else + mapping[raw_pos] = rich_pos + end + + rich_pos += 1 + end + + mapping + end + end + + # Takes an array of integers, and returns an array of ranges covering the same integers + def collapse_ranges(positions) + return [] if positions.empty? + ranges = [] + + start = prev = positions[0] + range = start..prev + positions[1..-1].each do |pos| + if pos == prev + 1 + range = start..pos + prev = pos + else + ranges << range + start = prev = pos + range = start..prev + end + end + ranges << range + + ranges + end + end +end diff --git a/lib/gitlab/string_regex_marker.rb b/lib/gitlab/string_regex_marker.rb new file mode 100644 index 00000000000..7ebf1c0428c --- /dev/null +++ b/lib/gitlab/string_regex_marker.rb @@ -0,0 +1,13 @@ +module Gitlab + class StringRegexMarker < StringRangeMarker + def mark(regex, group: 0, &block) + regex_match = raw_line.match(regex) + return rich_line unless regex_match + + begin_index, end_index = regex_match.offset(group) + name_range = begin_index..(end_index - 1) + + super([name_range], &block) + end + end +end diff --git a/lib/gitlab/url_builder.rb b/lib/gitlab/url_builder.rb index ccb456bcc94..23af9318d1a 100644 --- a/lib/gitlab/url_builder.rb +++ b/lib/gitlab/url_builder.rb @@ -61,7 +61,12 @@ module Gitlab elsif object.for_snippet? snippet = Snippet.find(object.noteable_id) - project_snippet_url(snippet, anchor: dom_id(object)) + + if snippet.is_a?(PersonalSnippet) + snippet_url(snippet, anchor: dom_id(object)) + else + project_snippet_url(snippet, anchor: dom_id(object)) + end end end diff --git a/lib/gitlab/url_sanitizer.rb b/lib/gitlab/url_sanitizer.rb index 9ce13feb79a..c81dc7e30d0 100644 --- a/lib/gitlab/url_sanitizer.rb +++ b/lib/gitlab/url_sanitizer.rb @@ -18,12 +18,6 @@ module Gitlab false end - def self.http_credentials_for_user(user) - return {} unless user.respond_to?(:username) - - { user: user.username } - end - def initialize(url, credentials: nil) @url = Addressable::URI.parse(url.strip) @credentials = credentials diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index e46ff313654..3b922da7ced 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -38,6 +38,16 @@ module Gitlab end end + def can_delete_branch?(ref) + return false unless can_access_git? + + if ProtectedBranch.protected?(project, ref) + user.can?(:delete_protected_branch, project) + else + user.can?(:push_code, project) + end + end + def can_push_to_branch?(ref) return false unless can_access_git? diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb index 4c395b4266e..fa182c4deda 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -21,5 +21,13 @@ module Gitlab nil end + + def boolean_to_yes_no(bool) + if bool + 'Yes' + else + 'No' + end + end end end diff --git a/lib/gitlab/visibility_level.rb b/lib/gitlab/visibility_level.rb index 2e31f4462f9..2b53798e70f 100644 --- a/lib/gitlab/visibility_level.rb +++ b/lib/gitlab/visibility_level.rb @@ -41,9 +41,9 @@ module Gitlab def options { - 'Private' => PRIVATE, - 'Internal' => INTERNAL, - 'Public' => PUBLIC + N_('VisibilityLevel|Private') => PRIVATE, + N_('VisibilityLevel|Internal') => INTERNAL, + N_('VisibilityLevel|Public') => PUBLIC } end @@ -83,7 +83,7 @@ module Gitlab end def valid_level?(level) - options.has_value?(level) + options.value?(level) end def level_name(level) diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 72875bdaa17..7f27317775c 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -22,7 +22,7 @@ module Gitlab params = { GL_ID: Gitlab::GlId.gl_id(user), GL_REPOSITORY: Gitlab::GlRepository.gl_repository(project, is_wiki), - RepoPath: repo_path, + RepoPath: repo_path } if Gitlab.config.gitaly.enabled @@ -31,8 +31,7 @@ module Gitlab feature_enabled = case action.to_s when 'git_receive_pack' - # Disabled for now, see https://gitlab.com/gitlab-org/gitaly/issues/172 - false + Gitlab::GitalyClient.feature_enabled?(:post_receive_pack) when 'git_upload_pack' Gitlab::GitalyClient.feature_enabled?(:post_upload_pack) when 'info_refs' @@ -51,7 +50,7 @@ module Gitlab { StoreLFSPath: "#{Gitlab.config.lfs.storage_path}/tmp/upload", LfsOid: oid, - LfsSize: size, + LfsSize: size } end @@ -62,7 +61,7 @@ module Gitlab def send_git_blob(repository, blob) params = { 'RepoPath' => repository.path_to_repo, - 'BlobId' => blob.id, + 'BlobId' => blob.id } [ @@ -127,10 +126,10 @@ module Gitlab 'Subprotocols' => terminal[:subprotocols], 'Url' => terminal[:url], 'Header' => terminal[:headers], - 'MaxSessionTime' => terminal[:max_session_time], + 'MaxSessionTime' => terminal[:max_session_time] } } - details['Terminal']['CAPem'] = terminal[:ca_pem] if terminal.has_key?(:ca_pem) + details['Terminal']['CAPem'] = terminal[:ca_pem] if terminal.key?(:ca_pem) details end @@ -165,7 +164,7 @@ module Gitlab encoded_message, secret, true, - { iss: 'gitlab-workhorse', verify_iss: true, algorithm: 'HS256' }, + { iss: 'gitlab-workhorse', verify_iss: true, algorithm: 'HS256' } ) end diff --git a/lib/rouge/lexers/math.rb b/lib/rouge/lexers/math.rb index 80784adfd76..939b23a3421 100644 --- a/lib/rouge/lexers/math.rb +++ b/lib/rouge/lexers/math.rb @@ -1,21 +1,9 @@ module Rouge module Lexers - class Math < Lexer + class Math < PlainText title "A passthrough lexer used for LaTeX input" - desc "A boring lexer that doesn't highlight anything" - + desc "PLEASE REFACTOR - this should be handled by SyntaxHighlightFilter" tag 'math' - mimetypes 'text/plain' - - default_options token: 'Text' - - def token - @token ||= Token[option :token] - end - - def stream_tokens(string, &b) - yield self.token, string - end end end end diff --git a/lib/rouge/lexers/plantuml.rb b/lib/rouge/lexers/plantuml.rb index 7d5700b7f6d..63c461764fc 100644 --- a/lib/rouge/lexers/plantuml.rb +++ b/lib/rouge/lexers/plantuml.rb @@ -1,21 +1,9 @@ module Rouge module Lexers - class Plantuml < Lexer + class Plantuml < PlainText title "A passthrough lexer used for PlantUML input" - desc "A boring lexer that doesn't highlight anything" - + desc "PLEASE REFACTOR - this should be handled by SyntaxHighlightFilter" tag 'plantuml' - mimetypes 'text/plain' - - default_options token: 'Text' - - def token - @token ||= Token[option :token] - end - - def stream_tokens(string, &b) - yield self.token, string - end end end end diff --git a/lib/support/init.d/gitlab b/lib/support/init.d/gitlab index 6e351365de0..c5f93336346 100755 --- a/lib/support/init.d/gitlab +++ b/lib/support/init.d/gitlab @@ -48,7 +48,7 @@ gitlab_pages_pid_path="$pid_path/gitlab-pages.pid" gitlab_pages_options="-pages-domain example.com -pages-root $app_root/shared/pages -listen-proxy 127.0.0.1:8090" gitlab_pages_log="$app_root/log/gitlab-pages.log" shell_path="/bin/bash" -gitaly_enabled=false +gitaly_enabled=true gitaly_dir=$(cd $app_root/../gitaly 2> /dev/null && pwd) gitaly_pid_path="$pid_path/gitaly.pid" gitaly_log="$app_root/log/gitaly.log" diff --git a/lib/support/init.d/gitlab.default.example b/lib/support/init.d/gitlab.default.example index 9472c3c992f..295c79fccfc 100644 --- a/lib/support/init.d/gitlab.default.example +++ b/lib/support/init.d/gitlab.default.example @@ -86,5 +86,7 @@ mail_room_pid_path="$pid_path/mail_room.pid" shell_path="/bin/bash" # This variable controls whether the init script starts/stops Gitaly -gitaly_enabled=false +gitaly_enabled=true +gitaly_dir=$(cd $app_root/../gitaly 2> /dev/null && pwd) +gitaly_pid_path="$pid_path/gitaly.pid" gitaly_log="$app_root/log/gitaly.log" diff --git a/lib/system_check.rb b/lib/system_check.rb new file mode 100644 index 00000000000..466c39904fa --- /dev/null +++ b/lib/system_check.rb @@ -0,0 +1,21 @@ +# Library to perform System Checks +# +# Every Check is implemented as its own class inherited from SystemCheck::BaseCheck +# Execution coordination and boilerplate output is done by the SystemCheck::SimpleExecutor +# +# This structure decouples checks from Rake tasks and facilitates unit-testing +module SystemCheck + # Executes a bunch of checks for specified component + # + # @param [String] component name of the component relative to the checks being executed + # @param [Array<BaseCheck>] checks classes of corresponding checks to be executed in the same order + def self.run(component, checks = []) + executor = SimpleExecutor.new(component) + + checks.each do |check| + executor << check + end + + executor.execute + end +end diff --git a/lib/system_check/app/active_users_check.rb b/lib/system_check/app/active_users_check.rb new file mode 100644 index 00000000000..1d72c8d6903 --- /dev/null +++ b/lib/system_check/app/active_users_check.rb @@ -0,0 +1,17 @@ +module SystemCheck + module App + class ActiveUsersCheck < SystemCheck::BaseCheck + set_name 'Active users:' + + def multi_check + active_users = User.active.count + + if active_users > 0 + $stdout.puts active_users.to_s.color(:green) + else + $stdout.puts active_users.to_s.color(:red) + end + end + end + end +end diff --git a/lib/system_check/app/database_config_exists_check.rb b/lib/system_check/app/database_config_exists_check.rb new file mode 100644 index 00000000000..d1fae192350 --- /dev/null +++ b/lib/system_check/app/database_config_exists_check.rb @@ -0,0 +1,25 @@ +module SystemCheck + module App + class DatabaseConfigExistsCheck < SystemCheck::BaseCheck + set_name 'Database config exists?' + + def check? + database_config_file = Rails.root.join('config', 'database.yml') + + File.exist?(database_config_file) + end + + def show_error + try_fixing_it( + 'Copy config/database.yml.<your db> to config/database.yml', + 'Check that the information in config/database.yml is correct' + ) + for_more_information( + 'doc/install/databases.md', + 'http://guides.rubyonrails.org/getting_started.html#configuring-a-database' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/git_config_check.rb b/lib/system_check/app/git_config_check.rb new file mode 100644 index 00000000000..198867f7ac6 --- /dev/null +++ b/lib/system_check/app/git_config_check.rb @@ -0,0 +1,42 @@ +module SystemCheck + module App + class GitConfigCheck < SystemCheck::BaseCheck + OPTIONS = { + 'core.autocrlf' => 'input' + }.freeze + + set_name 'Git configured correctly?' + + def check? + correct_options = OPTIONS.map do |name, value| + run_command(%W(#{Gitlab.config.git.bin_path} config --global --get #{name})).try(:squish) == value + end + + correct_options.all? + end + + # Tries to configure git itself + # + # Returns true if all subcommands were successful (according to their exit code) + # Returns false if any or all subcommands failed. + def repair! + return false unless is_gitlab_user? + + command_success = OPTIONS.map do |name, value| + system(*%W(#{Gitlab.config.git.bin_path} config --global #{name} #{value})) + end + + command_success.all? + end + + def show_error + try_fixing_it( + sudo_gitlab("\"#{Gitlab.config.git.bin_path}\" config --global core.autocrlf \"#{OPTIONS['core.autocrlf']}\"") + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + end + end + end +end diff --git a/lib/system_check/app/git_version_check.rb b/lib/system_check/app/git_version_check.rb new file mode 100644 index 00000000000..c388682dfb4 --- /dev/null +++ b/lib/system_check/app/git_version_check.rb @@ -0,0 +1,29 @@ +module SystemCheck + module App + class GitVersionCheck < SystemCheck::BaseCheck + set_name -> { "Git version >= #{self.required_version} ?" } + set_check_pass -> { "yes (#{self.current_version})" } + + def self.required_version + @required_version ||= Gitlab::VersionInfo.new(2, 7, 3) + end + + def self.current_version + @current_version ||= Gitlab::VersionInfo.parse(run_command(%W(#{Gitlab.config.git.bin_path} --version))) + end + + def check? + self.class.current_version.valid? && self.class.required_version <= self.class.current_version + end + + def show_error + $stdout.puts "Your git bin path is \"#{Gitlab.config.git.bin_path}\"" + + try_fixing_it( + "Update your git to a version >= #{self.class.required_version} from #{self.class.current_version}" + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/gitlab_config_exists_check.rb b/lib/system_check/app/gitlab_config_exists_check.rb new file mode 100644 index 00000000000..247aa0994e4 --- /dev/null +++ b/lib/system_check/app/gitlab_config_exists_check.rb @@ -0,0 +1,24 @@ +module SystemCheck + module App + class GitlabConfigExistsCheck < SystemCheck::BaseCheck + set_name 'GitLab config exists?' + + def check? + gitlab_config_file = Rails.root.join('config', 'gitlab.yml') + + File.exist?(gitlab_config_file) + end + + def show_error + try_fixing_it( + 'Copy config/gitlab.yml.example to config/gitlab.yml', + 'Update config/gitlab.yml to match your setup' + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/gitlab_config_up_to_date_check.rb b/lib/system_check/app/gitlab_config_up_to_date_check.rb new file mode 100644 index 00000000000..c609e48e133 --- /dev/null +++ b/lib/system_check/app/gitlab_config_up_to_date_check.rb @@ -0,0 +1,30 @@ +module SystemCheck + module App + class GitlabConfigUpToDateCheck < SystemCheck::BaseCheck + set_name 'GitLab config up to date?' + set_skip_reason "can't check because of previous errors" + + def skip? + gitlab_config_file = Rails.root.join('config', 'gitlab.yml') + !File.exist?(gitlab_config_file) + end + + def check? + # omniauth or ldap could have been deleted from the file + !Gitlab.config['git_host'] + end + + def show_error + try_fixing_it( + 'Back-up your config/gitlab.yml', + 'Copy config/gitlab.yml.example to config/gitlab.yml', + 'Update config/gitlab.yml to match your setup' + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/init_script_exists_check.rb b/lib/system_check/app/init_script_exists_check.rb new file mode 100644 index 00000000000..d246e058e86 --- /dev/null +++ b/lib/system_check/app/init_script_exists_check.rb @@ -0,0 +1,27 @@ +module SystemCheck + module App + class InitScriptExistsCheck < SystemCheck::BaseCheck + set_name 'Init script exists?' + set_skip_reason 'skipped (omnibus-gitlab has no init script)' + + def skip? + omnibus_gitlab? + end + + def check? + script_path = '/etc/init.d/gitlab' + File.exist?(script_path) + end + + def show_error + try_fixing_it( + 'Install the init script' + ) + for_more_information( + see_installation_guide_section 'Install Init Script' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/init_script_up_to_date_check.rb b/lib/system_check/app/init_script_up_to_date_check.rb new file mode 100644 index 00000000000..015c7ed1731 --- /dev/null +++ b/lib/system_check/app/init_script_up_to_date_check.rb @@ -0,0 +1,43 @@ +module SystemCheck + module App + class InitScriptUpToDateCheck < SystemCheck::BaseCheck + SCRIPT_PATH = '/etc/init.d/gitlab'.freeze + + set_name 'Init script up-to-date?' + set_skip_reason 'skipped (omnibus-gitlab has no init script)' + + def skip? + omnibus_gitlab? + end + + def multi_check + recipe_path = Rails.root.join('lib/support/init.d/', 'gitlab') + + unless File.exist?(SCRIPT_PATH) + $stdout.puts "can't check because of previous errors".color(:magenta) + return + end + + recipe_content = File.read(recipe_path) + script_content = File.read(SCRIPT_PATH) + + if recipe_content == script_content + $stdout.puts 'yes'.color(:green) + else + $stdout.puts 'no'.color(:red) + show_error + end + end + + def show_error + try_fixing_it( + 'Re-download the init script' + ) + for_more_information( + see_installation_guide_section 'Install Init Script' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/log_writable_check.rb b/lib/system_check/app/log_writable_check.rb new file mode 100644 index 00000000000..3e0c436d6ee --- /dev/null +++ b/lib/system_check/app/log_writable_check.rb @@ -0,0 +1,28 @@ +module SystemCheck + module App + class LogWritableCheck < SystemCheck::BaseCheck + set_name 'Log directory writable?' + + def check? + File.writable?(log_path) + end + + def show_error + try_fixing_it( + "sudo chown -R gitlab #{log_path}", + "sudo chmod -R u+rwX #{log_path}" + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + + private + + def log_path + Rails.root.join('log') + end + end + end +end diff --git a/lib/system_check/app/migrations_are_up_check.rb b/lib/system_check/app/migrations_are_up_check.rb new file mode 100644 index 00000000000..5eedbacce77 --- /dev/null +++ b/lib/system_check/app/migrations_are_up_check.rb @@ -0,0 +1,20 @@ +module SystemCheck + module App + class MigrationsAreUpCheck < SystemCheck::BaseCheck + set_name 'All migrations up?' + + def check? + migration_status, _ = Gitlab::Popen.popen(%w(bundle exec rake db:migrate:status)) + + migration_status !~ /down\s+\d{14}/ + end + + def show_error + try_fixing_it( + sudo_gitlab('bundle exec rake db:migrate RAILS_ENV=production') + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/orphaned_group_members_check.rb b/lib/system_check/app/orphaned_group_members_check.rb new file mode 100644 index 00000000000..2b46d36fe51 --- /dev/null +++ b/lib/system_check/app/orphaned_group_members_check.rb @@ -0,0 +1,20 @@ +module SystemCheck + module App + class OrphanedGroupMembersCheck < SystemCheck::BaseCheck + set_name 'Database contains orphaned GroupMembers?' + set_check_pass 'no' + set_check_fail 'yes' + + def check? + !GroupMember.where('user_id not in (select id from users)').exists? + end + + def show_error + try_fixing_it( + 'You can delete the orphaned records using something along the lines of:', + sudo_gitlab("bundle exec rails runner -e production 'GroupMember.where(\"user_id NOT IN (SELECT id FROM users)\").delete_all'") + ) + end + end + end +end diff --git a/lib/system_check/app/projects_have_namespace_check.rb b/lib/system_check/app/projects_have_namespace_check.rb new file mode 100644 index 00000000000..a6ec9f7665c --- /dev/null +++ b/lib/system_check/app/projects_have_namespace_check.rb @@ -0,0 +1,37 @@ +module SystemCheck + module App + class ProjectsHaveNamespaceCheck < SystemCheck::BaseCheck + set_name 'Projects have namespace:' + set_skip_reason "can't check, you have no projects" + + def skip? + !Project.exists? + end + + def multi_check + $stdout.puts '' + + Project.find_each(batch_size: 100) do |project| + $stdout.print sanitized_message(project) + + if project.namespace + $stdout.puts 'yes'.color(:green) + else + $stdout.puts 'no'.color(:red) + show_error + end + end + end + + def show_error + try_fixing_it( + "Migrate global projects" + ) + for_more_information( + "doc/update/5.4-to-6.0.md in section \"#global-projects\"" + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/redis_version_check.rb b/lib/system_check/app/redis_version_check.rb new file mode 100644 index 00000000000..a0610e73576 --- /dev/null +++ b/lib/system_check/app/redis_version_check.rb @@ -0,0 +1,25 @@ +module SystemCheck + module App + class RedisVersionCheck < SystemCheck::BaseCheck + MIN_REDIS_VERSION = '2.8.0'.freeze + set_name "Redis version >= #{MIN_REDIS_VERSION}?" + + def check? + redis_version = run_command(%w(redis-cli --version)) + redis_version = redis_version.try(:match, /redis-cli (\d+\.\d+\.\d+)/) + + redis_version && (Gem::Version.new(redis_version[1]) > Gem::Version.new(MIN_REDIS_VERSION)) + end + + def show_error + try_fixing_it( + "Update your redis server to a version >= #{MIN_REDIS_VERSION}" + ) + for_more_information( + 'gitlab-public-wiki/wiki/Trouble-Shooting-Guide in section sidekiq' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/ruby_version_check.rb b/lib/system_check/app/ruby_version_check.rb new file mode 100644 index 00000000000..fd82f5f8a4a --- /dev/null +++ b/lib/system_check/app/ruby_version_check.rb @@ -0,0 +1,27 @@ +module SystemCheck + module App + class RubyVersionCheck < SystemCheck::BaseCheck + set_name -> { "Ruby version >= #{self.required_version} ?" } + set_check_pass -> { "yes (#{self.current_version})" } + + def self.required_version + @required_version ||= Gitlab::VersionInfo.new(2, 3, 3) + end + + def self.current_version + @current_version ||= Gitlab::VersionInfo.parse(run_command(%w(ruby --version))) + end + + def check? + self.class.current_version.valid? && self.class.required_version <= self.class.current_version + end + + def show_error + try_fixing_it( + "Update your ruby to a version >= #{self.class.required_version} from #{self.class.current_version}" + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/tmp_writable_check.rb b/lib/system_check/app/tmp_writable_check.rb new file mode 100644 index 00000000000..99a75e57abf --- /dev/null +++ b/lib/system_check/app/tmp_writable_check.rb @@ -0,0 +1,28 @@ +module SystemCheck + module App + class TmpWritableCheck < SystemCheck::BaseCheck + set_name 'Tmp directory writable?' + + def check? + File.writable?(tmp_path) + end + + def show_error + try_fixing_it( + "sudo chown -R gitlab #{tmp_path}", + "sudo chmod -R u+rwX #{tmp_path}" + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + + private + + def tmp_path + Rails.root.join('tmp') + end + end + end +end diff --git a/lib/system_check/app/uploads_directory_exists_check.rb b/lib/system_check/app/uploads_directory_exists_check.rb new file mode 100644 index 00000000000..7026d0ba075 --- /dev/null +++ b/lib/system_check/app/uploads_directory_exists_check.rb @@ -0,0 +1,21 @@ +module SystemCheck + module App + class UploadsDirectoryExistsCheck < SystemCheck::BaseCheck + set_name 'Uploads directory exists?' + + def check? + File.directory?(Rails.root.join('public/uploads')) + end + + def show_error + try_fixing_it( + "sudo -u #{gitlab_user} mkdir #{Rails.root}/public/uploads" + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/uploads_path_permission_check.rb b/lib/system_check/app/uploads_path_permission_check.rb new file mode 100644 index 00000000000..7df6c060254 --- /dev/null +++ b/lib/system_check/app/uploads_path_permission_check.rb @@ -0,0 +1,36 @@ +module SystemCheck + module App + class UploadsPathPermissionCheck < SystemCheck::BaseCheck + set_name 'Uploads directory has correct permissions?' + set_skip_reason 'skipped (no uploads folder found)' + + def skip? + !File.directory?(rails_uploads_path) + end + + def check? + File.stat(uploads_fullpath).mode == 040700 + end + + def show_error + try_fixing_it( + "sudo chmod 700 #{uploads_fullpath}" + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + + private + + def rails_uploads_path + Rails.root.join('public/uploads') + end + + def uploads_fullpath + File.realpath(rails_uploads_path) + end + end + end +end diff --git a/lib/system_check/app/uploads_path_tmp_permission_check.rb b/lib/system_check/app/uploads_path_tmp_permission_check.rb new file mode 100644 index 00000000000..b276a81eac1 --- /dev/null +++ b/lib/system_check/app/uploads_path_tmp_permission_check.rb @@ -0,0 +1,40 @@ +module SystemCheck + module App + class UploadsPathTmpPermissionCheck < SystemCheck::BaseCheck + set_name 'Uploads directory tmp has correct permissions?' + set_skip_reason 'skipped (no tmp uploads folder yet)' + + def skip? + !File.directory?(uploads_fullpath) || !Dir.exist?(upload_path_tmp) + end + + def check? + # If tmp upload dir has incorrect permissions, assume others do as well + # Verify drwx------ permissions + File.stat(upload_path_tmp).mode == 040700 && File.owned?(upload_path_tmp) + end + + def show_error + try_fixing_it( + "sudo chown -R #{gitlab_user} #{uploads_fullpath}", + "sudo find #{uploads_fullpath} -type f -exec chmod 0644 {} \\;", + "sudo find #{uploads_fullpath} -type d -not -path #{uploads_fullpath} -exec chmod 0700 {} \\;" + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + + private + + def upload_path_tmp + File.join(uploads_fullpath, 'tmp') + end + + def uploads_fullpath + File.realpath(Rails.root.join('public/uploads')) + end + end + end +end diff --git a/lib/system_check/base_check.rb b/lib/system_check/base_check.rb new file mode 100644 index 00000000000..5dcb3f0886b --- /dev/null +++ b/lib/system_check/base_check.rb @@ -0,0 +1,129 @@ +module SystemCheck + # Base class for Checks. You must inherit from here + # and implement the methods below when necessary + class BaseCheck + include ::SystemCheck::Helpers + + # Define a custom term for when check passed + # + # @param [String] term used when check passed (default: 'yes') + def self.set_check_pass(term) + @check_pass = term + end + + # Define a custom term for when check failed + # + # @param [String] term used when check failed (default: 'no') + def self.set_check_fail(term) + @check_fail = term + end + + # Define the name of the SystemCheck that will be displayed during execution + # + # @param [String] name of the check + def self.set_name(name) + @name = name + end + + # Define the reason why we skipped the SystemCheck + # + # This is only used if subclass implements `#skip?` + # + # @param [String] reason to be displayed + def self.set_skip_reason(reason) + @skip_reason = reason + end + + # Term to be displayed when check passed + # + # @return [String] term when check passed ('yes' if not re-defined in a subclass) + def self.check_pass + call_or_return(@check_pass) || 'yes' + end + + ## Term to be displayed when check failed + # + # @return [String] term when check failed ('no' if not re-defined in a subclass) + def self.check_fail + call_or_return(@check_fail) || 'no' + end + + # Name of the SystemCheck defined by the subclass + # + # @return [String] the name + def self.display_name + call_or_return(@name) || self.name + end + + # Skip reason defined by the subclass + # + # @return [String] the reason + def self.skip_reason + call_or_return(@skip_reason) || 'skipped' + end + + # Does the check support automatically repair routine? + # + # @return [Boolean] whether check implemented `#repair!` method or not + def can_repair? + self.class.instance_methods(false).include?(:repair!) + end + + def can_skip? + self.class.instance_methods(false).include?(:skip?) + end + + def is_multi_check? + self.class.instance_methods(false).include?(:multi_check) + end + + # Execute the check routine + # + # This is where you should implement the main logic that will return + # a boolean at the end + # + # You should not print any output to STDOUT here, use the specific methods instead + # + # @return [Boolean] whether check passed or failed + def check? + raise NotImplementedError + end + + # Execute a custom check that cover multiple unities + # + # When using multi_check you have to provide the output yourself + def multi_check + raise NotImplementedError + end + + # Prints troubleshooting instructions + # + # This is where you should print detailed information for any error found during #check? + # + # You may use helper methods to help format the output: + # + # @see #try_fixing_it + # @see #fix_and_rerun + # @see #for_more_infromation + def show_error + raise NotImplementedError + end + + # When implemented by a subclass, will attempt to fix the issue automatically + def repair! + raise NotImplementedError + end + + # When implemented by a subclass, will evaluate whether check should be skipped or not + # + # @return [Boolean] whether or not this check should be skipped + def skip? + raise NotImplementedError + end + + def self.call_or_return(input) + input.respond_to?(:call) ? input.call : input + end + private_class_method :call_or_return + end +end diff --git a/lib/system_check/helpers.rb b/lib/system_check/helpers.rb new file mode 100644 index 00000000000..c42ae4fe4c4 --- /dev/null +++ b/lib/system_check/helpers.rb @@ -0,0 +1,75 @@ +require 'tasks/gitlab/task_helpers' + +module SystemCheck + module Helpers + include ::Gitlab::TaskHelpers + + # Display a message telling to fix and rerun the checks + def fix_and_rerun + $stdout.puts ' Please fix the error above and rerun the checks.'.color(:red) + end + + # Display a formatted list of references (documentation or links) where to find more information + # + # @param [Array<String>] sources one or more references (documentation or links) + def for_more_information(*sources) + $stdout.puts ' For more information see:'.color(:blue) + sources.each do |source| + $stdout.puts " #{source}" + end + end + + def see_installation_guide_section(section) + "doc/install/installation.md in section \"#{section}\"" + end + + # @deprecated This will no longer be used when all checks were executed using SystemCheck + def finished_checking(component) + $stdout.puts '' + $stdout.puts "Checking #{component.color(:yellow)} ... #{'Finished'.color(:green)}" + $stdout.puts '' + end + + # @deprecated This will no longer be used when all checks were executed using SystemCheck + def start_checking(component) + $stdout.puts "Checking #{component.color(:yellow)} ..." + $stdout.puts '' + end + + # Display a formatted list of instructions on how to fix the issue identified by the #check? + # + # @param [Array<String>] steps one or short sentences with help how to fix the issue + def try_fixing_it(*steps) + steps = steps.shift if steps.first.is_a?(Array) + + $stdout.puts ' Try fixing it:'.color(:blue) + steps.each do |step| + $stdout.puts " #{step}" + end + end + + def sanitized_message(project) + if should_sanitize? + "#{project.namespace_id.to_s.color(:yellow)}/#{project.id.to_s.color(:yellow)} ... " + else + "#{project.name_with_namespace.color(:yellow)} ... " + end + end + + def should_sanitize? + if ENV['SANITIZE'] == 'true' + true + else + false + end + end + + def omnibus_gitlab? + Dir.pwd == '/opt/gitlab/embedded/service/gitlab-rails' + end + + def sudo_gitlab(command) + "sudo -u #{gitlab_user} -H #{command}" + end + end +end diff --git a/lib/system_check/simple_executor.rb b/lib/system_check/simple_executor.rb new file mode 100644 index 00000000000..dc2d4643a01 --- /dev/null +++ b/lib/system_check/simple_executor.rb @@ -0,0 +1,99 @@ +module SystemCheck + # Simple Executor is current default executor for GitLab + # It is a simple port from display logic in the old check.rake + # + # There is no concurrency level and the output is progressively + # printed into the STDOUT + # + # @attr_reader [Array<BaseCheck>] checks classes of corresponding checks to be executed in the same order + # @attr_reader [String] component name of the component relative to the checks being executed + class SimpleExecutor + attr_reader :checks + attr_reader :component + + # @param [String] component name of the component relative to the checks being executed + def initialize(component) + raise ArgumentError unless component.is_a? String + + @component = component + @checks = Set.new + end + + # Add a check to be executed + # + # @param [BaseCheck] check class + def <<(check) + raise ArgumentError unless check < BaseCheck + @checks << check + end + + # Executes defined checks in the specified order and outputs confirmation or error information + def execute + start_checking(component) + + @checks.each do |check| + run_check(check) + end + + finished_checking(component) + end + + # Executes a single check + # + # @param [SystemCheck::BaseCheck] check_klass + def run_check(check_klass) + $stdout.print "#{check_klass.display_name} ... " + + check = check_klass.new + + # When implements skip method, we run it first, and if true, skip the check + if check.can_skip? && check.skip? + $stdout.puts check_klass.skip_reason.color(:magenta) + return + end + + # When implements a multi check, we don't control the output + if check.is_multi_check? + check.multi_check + return + end + + if check.check? + $stdout.puts check_klass.check_pass.color(:green) + else + $stdout.puts check_klass.check_fail.color(:red) + + if check.can_repair? + $stdout.print 'Trying to fix error automatically. ...' + if check.repair! + $stdout.puts 'Success'.color(:green) + return + else + $stdout.puts 'Failed'.color(:red) + end + end + + check.show_error + end + end + + private + + # Prints header content for the series of checks to be executed for this component + # + # @param [String] component name of the component relative to the checks being executed + def start_checking(component) + $stdout.puts "Checking #{component.color(:yellow)} ..." + $stdout.puts '' + end + + # Prints footer content for the series of checks executed for this component + # + # @param [String] component name of the component relative to the checks being executed + def finished_checking(component) + $stdout.puts '' + $stdout.puts "Checking #{component.color(:yellow)} ... #{'Finished'.color(:green)}" + $stdout.puts '' + end + end +end diff --git a/lib/tasks/gemojione.rake b/lib/tasks/gemojione.rake index b5572a39d30..87ca39b079b 100644 --- a/lib/tasks/gemojione.rake +++ b/lib/tasks/gemojione.rake @@ -21,7 +21,7 @@ namespace :gemojione do moji: emoji_hash['moji'], description: emoji_hash['description'], unicodeVersion: Gitlab::Emoji.emoji_unicode_version(name), - digest: hash_digest, + digest: hash_digest } resultant_emoji_map[name] = entry diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake index 0aa21a4bd13..b27f7475115 100644 --- a/lib/tasks/gettext.rake +++ b/lib/tasks/gettext.rake @@ -11,4 +11,12 @@ namespace :gettext do "{#{folders}}/**/*.{#{exts}}" ) end + + task :compile do + # See: https://gitlab.com/gitlab-org/gitlab-ce/issues/33014#note_31218998 + FileUtils.touch(File.join(Rails.root, 'locale/gitlab.pot')) + + Rake::Task['gettext:pack'].invoke + Rake::Task['gettext:po_to_json'].invoke + end end diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake index f41c73154f5..63c5e9b9c83 100644 --- a/lib/tasks/gitlab/check.rake +++ b/lib/tasks/gitlab/check.rake @@ -1,5 +1,9 @@ +# Temporary hack, until we migrate all checks to SystemCheck format +require 'system_check' +require 'system_check/helpers' + namespace :gitlab do - desc "GitLab | Check the configuration of GitLab and its environment" + desc 'GitLab | Check the configuration of GitLab and its environment' task check: %w{gitlab:gitlab_shell:check gitlab:sidekiq:check gitlab:incoming_email:check @@ -7,331 +11,38 @@ namespace :gitlab do gitlab:app:check} namespace :app do - desc "GitLab | Check the configuration of the GitLab Rails app" + desc 'GitLab | Check the configuration of the GitLab Rails app' task check: :environment do warn_user_is_not_gitlab - start_checking "GitLab" - - check_git_config - check_database_config_exists - check_migrations_are_up - check_orphaned_group_members - check_gitlab_config_exists - check_gitlab_config_not_outdated - check_log_writable - check_tmp_writable - check_uploads - check_init_script_exists - check_init_script_up_to_date - check_projects_have_namespace - check_redis_version - check_ruby_version - check_git_version - check_active_users - - finished_checking "GitLab" - end - - # Checks - ######################## - - def check_git_config - print "Git configured with autocrlf=input? ... " - - options = { - "core.autocrlf" => "input" - } - - correct_options = options.map do |name, value| - run_command(%W(#{Gitlab.config.git.bin_path} config --global --get #{name})).try(:squish) == value - end - - if correct_options.all? - puts "yes".color(:green) - else - print "Trying to fix Git error automatically. ..." - - if auto_fix_git_config(options) - puts "Success".color(:green) - else - puts "Failed".color(:red) - try_fixing_it( - sudo_gitlab("\"#{Gitlab.config.git.bin_path}\" config --global core.autocrlf \"#{options["core.autocrlf"]}\"") - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - end - end - end - - def check_database_config_exists - print "Database config exists? ... " - - database_config_file = Rails.root.join("config", "database.yml") - - if File.exist?(database_config_file) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Copy config/database.yml.<your db> to config/database.yml", - "Check that the information in config/database.yml is correct" - ) - for_more_information( - see_database_guide, - "http://guides.rubyonrails.org/getting_started.html#configuring-a-database" - ) - fix_and_rerun - end - end - - def check_gitlab_config_exists - print "GitLab config exists? ... " - - gitlab_config_file = Rails.root.join("config", "gitlab.yml") - - if File.exist?(gitlab_config_file) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Copy config/gitlab.yml.example to config/gitlab.yml", - "Update config/gitlab.yml to match your setup" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - end - - def check_gitlab_config_not_outdated - print "GitLab config outdated? ... " - - gitlab_config_file = Rails.root.join("config", "gitlab.yml") - unless File.exist?(gitlab_config_file) - puts "can't check because of previous errors".color(:magenta) - end - - # omniauth or ldap could have been deleted from the file - unless Gitlab.config['git_host'] - puts "no".color(:green) - else - puts "yes".color(:red) - try_fixing_it( - "Backup your config/gitlab.yml", - "Copy config/gitlab.yml.example to config/gitlab.yml", - "Update config/gitlab.yml to match your setup" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - end - - def check_init_script_exists - print "Init script exists? ... " - - if omnibus_gitlab? - puts 'skipped (omnibus-gitlab has no init script)'.color(:magenta) - return - end - - script_path = "/etc/init.d/gitlab" - - if File.exist?(script_path) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Install the init script" - ) - for_more_information( - see_installation_guide_section "Install Init Script" - ) - fix_and_rerun - end - end - - def check_init_script_up_to_date - print "Init script up-to-date? ... " - - if omnibus_gitlab? - puts 'skipped (omnibus-gitlab has no init script)'.color(:magenta) - return - end - - recipe_path = Rails.root.join("lib/support/init.d/", "gitlab") - script_path = "/etc/init.d/gitlab" - - unless File.exist?(script_path) - puts "can't check because of previous errors".color(:magenta) - return - end - - recipe_content = File.read(recipe_path) - script_content = File.read(script_path) - - if recipe_content == script_content - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Redownload the init script" - ) - for_more_information( - see_installation_guide_section "Install Init Script" - ) - fix_and_rerun - end - end - - def check_migrations_are_up - print "All migrations up? ... " - - migration_status, _ = Gitlab::Popen.popen(%w(bundle exec rake db:migrate:status)) - - unless migration_status =~ /down\s+\d{14}/ - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - sudo_gitlab("bundle exec rake db:migrate RAILS_ENV=production") - ) - fix_and_rerun - end - end - - def check_orphaned_group_members - print "Database contains orphaned GroupMembers? ... " - if GroupMember.where("user_id not in (select id from users)").count > 0 - puts "yes".color(:red) - try_fixing_it( - "You can delete the orphaned records using something along the lines of:", - sudo_gitlab("bundle exec rails runner -e production 'GroupMember.where(\"user_id NOT IN (SELECT id FROM users)\").delete_all'") - ) - else - puts "no".color(:green) - end - end - - def check_log_writable - print "Log directory writable? ... " - - log_path = Rails.root.join("log") - - if File.writable?(log_path) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "sudo chown -R gitlab #{log_path}", - "sudo chmod -R u+rwX #{log_path}" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - end - def check_tmp_writable - print "Tmp directory writable? ... " - - tmp_path = Rails.root.join("tmp") - - if File.writable?(tmp_path) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "sudo chown -R gitlab #{tmp_path}", - "sudo chmod -R u+rwX #{tmp_path}" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - end - - def check_uploads - print "Uploads directory setup correctly? ... " - - unless File.directory?(Rails.root.join('public/uploads')) - puts "no".color(:red) - try_fixing_it( - "sudo -u #{gitlab_user} mkdir #{Rails.root}/public/uploads" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - return - end - - upload_path = File.realpath(Rails.root.join('public/uploads')) - upload_path_tmp = File.join(upload_path, 'tmp') - - if File.stat(upload_path).mode == 040700 - unless Dir.exist?(upload_path_tmp) - puts 'skipped (no tmp uploads folder yet)'.color(:magenta) - return - end - - # If tmp upload dir has incorrect permissions, assume others do as well - # Verify drwx------ permissions - if File.stat(upload_path_tmp).mode == 040700 && File.owned?(upload_path_tmp) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "sudo chown -R #{gitlab_user} #{upload_path}", - "sudo find #{upload_path} -type f -exec chmod 0644 {} \\;", - "sudo find #{upload_path} -type d -not -path #{upload_path} -exec chmod 0700 {} \\;" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - else - puts "no".color(:red) - try_fixing_it( - "sudo chmod 700 #{upload_path}" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - end - - def check_redis_version - min_redis_version = "2.8.0" - print "Redis version >= #{min_redis_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)) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Update your redis server to a version >= #{min_redis_version}" - ) - for_more_information( - "gitlab-public-wiki/wiki/Trouble-Shooting-Guide in section sidekiq" - ) - fix_and_rerun - end + checks = [ + SystemCheck::App::GitConfigCheck, + SystemCheck::App::DatabaseConfigExistsCheck, + SystemCheck::App::MigrationsAreUpCheck, + SystemCheck::App::OrphanedGroupMembersCheck, + SystemCheck::App::GitlabConfigExistsCheck, + SystemCheck::App::GitlabConfigUpToDateCheck, + SystemCheck::App::LogWritableCheck, + SystemCheck::App::TmpWritableCheck, + SystemCheck::App::UploadsDirectoryExistsCheck, + SystemCheck::App::UploadsPathPermissionCheck, + SystemCheck::App::UploadsPathTmpPermissionCheck, + SystemCheck::App::InitScriptExistsCheck, + SystemCheck::App::InitScriptUpToDateCheck, + SystemCheck::App::ProjectsHaveNamespaceCheck, + SystemCheck::App::RedisVersionCheck, + SystemCheck::App::RubyVersionCheck, + SystemCheck::App::GitVersionCheck, + SystemCheck::App::ActiveUsersCheck + ] + + SystemCheck.run('GitLab', checks) end end namespace :gitlab_shell do + include SystemCheck::Helpers + desc "GitLab | Check the configuration of GitLab Shell" task check: :environment do warn_user_is_not_gitlab @@ -513,33 +224,6 @@ namespace :gitlab do end end - def check_projects_have_namespace - print "projects have namespace: ... " - - unless Project.count > 0 - puts "can't check, you have no projects".color(:magenta) - return - end - puts "" - - Project.find_each(batch_size: 100) do |project| - print sanitized_message(project) - - if project.namespace - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Migrate global projects" - ) - for_more_information( - "doc/update/5.4-to-6.0.md in section \"#global-projects\"" - ) - fix_and_rerun - end - end - end - # Helper methods ######################## @@ -565,6 +249,8 @@ namespace :gitlab do end namespace :sidekiq do + include SystemCheck::Helpers + desc "GitLab | Check the configuration of Sidekiq" task check: :environment do warn_user_is_not_gitlab @@ -623,6 +309,8 @@ namespace :gitlab do end namespace :incoming_email do + include SystemCheck::Helpers + desc "GitLab | Check the configuration of Reply by email" task check: :environment do warn_user_is_not_gitlab @@ -757,6 +445,8 @@ namespace :gitlab do end namespace :ldap do + include SystemCheck::Helpers + task :check, [:limit] => :environment do |_, args| # Only show up to 100 results because LDAP directories can be very big. # This setting only affects the `rake gitlab:check` script. @@ -812,6 +502,8 @@ namespace :gitlab do end namespace :repo do + include SystemCheck::Helpers + desc "GitLab | Check the integrity of the repositories managed by GitLab" task check: :environment do Gitlab.config.repositories.storages.each do |name, repository_storage| @@ -826,6 +518,8 @@ namespace :gitlab do end namespace :user do + include SystemCheck::Helpers + desc "GitLab | Check the integrity of a specific user's repositories" task :check_repos, [:username] => :environment do |t, args| username = args[:username] || prompt("Check repository integrity for fsername? ".color(:blue)) @@ -848,55 +542,6 @@ namespace :gitlab do # Helper methods ########################## - def fix_and_rerun - puts " Please fix the error above and rerun the checks.".color(:red) - end - - def for_more_information(*sources) - sources = sources.shift if sources.first.is_a?(Array) - - puts " For more information see:".color(:blue) - sources.each do |source| - puts " #{source}" - end - end - - def finished_checking(component) - puts "" - puts "Checking #{component.color(:yellow)} ... #{"Finished".color(:green)}" - puts "" - end - - def see_database_guide - "doc/install/databases.md" - end - - def see_installation_guide_section(section) - "doc/install/installation.md in section \"#{section}\"" - end - - def sudo_gitlab(command) - "sudo -u #{gitlab_user} -H #{command}" - end - - def gitlab_user - Gitlab.config.gitlab.user - end - - def start_checking(component) - puts "Checking #{component.color(:yellow)} ..." - puts "" - end - - def try_fixing_it(*steps) - steps = steps.shift if steps.first.is_a?(Array) - - puts " Try fixing it:".color(:blue) - steps.each do |step| - puts " #{step}" - end - end - def check_gitlab_shell required_version = Gitlab::VersionInfo.new(gitlab_shell_major_version, gitlab_shell_minor_version, gitlab_shell_patch_version) current_version = Gitlab::VersionInfo.parse(gitlab_shell_version) @@ -909,65 +554,6 @@ namespace :gitlab do end end - def check_ruby_version - required_version = Gitlab::VersionInfo.new(2, 1, 0) - current_version = Gitlab::VersionInfo.parse(run_command(%w(ruby --version))) - - print "Ruby version >= #{required_version} ? ... " - - if current_version.valid? && required_version <= current_version - puts "yes (#{current_version})".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Update your ruby to a version >= #{required_version} from #{current_version}" - ) - fix_and_rerun - end - end - - def check_git_version - required_version = Gitlab::VersionInfo.new(2, 7, 3) - 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} ? ... " - - if current_version.valid? && required_version <= current_version - puts "yes (#{current_version})".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Update your git to a version >= #{required_version} from #{current_version}" - ) - fix_and_rerun - end - end - - def check_active_users - puts "Active users: #{User.active.count}" - end - - def omnibus_gitlab? - Dir.pwd == '/opt/gitlab/embedded/service/gitlab-rails' - end - - def sanitized_message(project) - if should_sanitize? - "#{project.namespace_id.to_s.color(:yellow)}/#{project.id.to_s.color(:yellow)} ... " - else - "#{project.name_with_namespace.color(:yellow)} ... " - end - end - - def should_sanitize? - if ENV['SANITIZE'] == "true" - true - else - false - end - end - def check_repo_integrity(repo_dir) puts "\nChecking repo at #{repo_dir.color(:yellow)}" diff --git a/lib/tasks/gitlab/info.rake b/lib/tasks/gitlab/info.rake index a2a2db487b7..e3883278886 100644 --- a/lib/tasks/gitlab/info.rake +++ b/lib/tasks/gitlab/info.rake @@ -16,6 +16,8 @@ namespace :gitlab do redis_version = run_and_match(%w(redis-cli --version), /redis-cli (\d+\.\d+\.\d+)/).to_a # check Git version git_version = run_and_match([Gitlab.config.git.bin_path, '--version'], /git version ([\d\.]+)/).to_a + # check Go version + go_version = run_and_match(%w(go version), /go version (.+)/).to_a puts "" puts "System information".color(:yellow) @@ -30,6 +32,7 @@ namespace :gitlab do puts "Redis Version:\t#{redis_version[1] || "unknown".color(:red)}" puts "Git Version:\t#{git_version[1] || "unknown".color(:red)}" puts "Sidekiq Version:#{Sidekiq::VERSION}" + puts "Go Version:\t#{go_version[1] || "unknown".color(:red)}" # check database adapter database_adapter = ActiveRecord::Base.connection.adapter_name.downcase diff --git a/lib/tasks/gitlab/task_helpers.rb b/lib/tasks/gitlab/task_helpers.rb index e3c9d3b491c..964aa0fe1bc 100644 --- a/lib/tasks/gitlab/task_helpers.rb +++ b/lib/tasks/gitlab/task_helpers.rb @@ -98,34 +98,30 @@ module Gitlab end end + def gitlab_user + Gitlab.config.gitlab.user + end + + def is_gitlab_user? + return @is_gitlab_user unless @is_gitlab_user.nil? + + current_user = run_command(%w(whoami)).chomp + @is_gitlab_user = current_user == gitlab_user + end + def warn_user_is_not_gitlab - unless @warned_user_not_gitlab - gitlab_user = Gitlab.config.gitlab.user + return if @warned_user_not_gitlab + + unless is_gitlab_user? 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." - puts " Things may work\/fail for the wrong reasons." - puts " For correct results you should run this as user #{gitlab_user.color(:magenta)}." - puts "" - end - @warned_user_not_gitlab = true - end - end - # Tries to configure git itself - # - # Returns true if all subcommands were successfull (according to their exit code) - # Returns false if any or all subcommands failed. - def auto_fix_git_config(options) - if !@warned_user_not_gitlab - command_success = options.map do |name, value| - system(*%W(#{Gitlab.config.git.bin_path} config --global #{name} #{value})) - end + puts " Warning ".color(:black).background(:yellow) + puts " You are running as user #{current_user.color(:magenta)}, we hope you know what you are doing." + puts " Things may work\/fail for the wrong reasons." + puts " For correct results you should run this as user #{gitlab_user.color(:magenta)}." + puts "" - command_success.all? - else - false + @warned_user_not_gitlab = true end end diff --git a/lib/tasks/gitlab/two_factor.rake b/lib/tasks/gitlab/two_factor.rake index fc0ccc726ed..7728c485e8d 100644 --- a/lib/tasks/gitlab/two_factor.rake +++ b/lib/tasks/gitlab/two_factor.rake @@ -19,5 +19,21 @@ namespace :gitlab do puts "There are currently no users with 2FA enabled.".color(:yellow) end end + + namespace :rotate_key do + def rotator + @rotator ||= Gitlab::OtpKeyRotator.new(ENV['filename']) + end + + desc "Encrypt user OTP secrets with a new encryption key" + task apply: :environment do |t, args| + rotator.rotate!(old_key: ENV['old_key'], new_key: ENV['new_key']) + end + + desc "Rollback to secrets encrypted with the old encryption key" + task rollback: :environment do + rotator.rollback! + end + end end end diff --git a/lib/tasks/gitlab/update_templates.rake b/lib/tasks/gitlab/update_templates.rake index 1b04e1350ed..59c32bbe7a4 100644 --- a/lib/tasks/gitlab/update_templates.rake +++ b/lib/tasks/gitlab/update_templates.rake @@ -49,7 +49,7 @@ namespace :gitlab do Template.new( "https://gitlab.com/gitlab-org/Dockerfile.git", /(\.{1,2}|LICENSE|CONTRIBUTING.md|\.Dockerfile)\z/ - ), + ) ].freeze def vendor_directory diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake index bc76d7edc55..50b8e331469 100644 --- a/lib/tasks/import.rake +++ b/lib/tasks/import.rake @@ -37,7 +37,7 @@ class GithubImport end def import! - @project.import_start + @project.force_import_start timings = Benchmark.measure do Github::Import.new(@project, @options).execute diff --git a/lib/tasks/spec.rake b/lib/tasks/spec.rake index 602c60be828..2eddcb3c777 100644 --- a/lib/tasks/spec.rake +++ b/lib/tasks/spec.rake @@ -60,7 +60,7 @@ desc "GitLab | Run specs" task :spec do cmds = [ %w(rake gitlab:setup), - %w(rspec spec), + %w(rspec spec) ] run_commands(cmds) end diff --git a/lib/tasks/tokens.rake b/lib/tasks/tokens.rake index 95735f43802..ad1818ff1fa 100644 --- a/lib/tasks/tokens.rake +++ b/lib/tasks/tokens.rake @@ -11,6 +11,11 @@ namespace :tokens do reset_all_users_token(:reset_incoming_email_token!) end + desc "Reset all GitLab RSS tokens" + task reset_all_rss: :environment do + reset_all_users_token(:reset_rss_token!) + end + def reset_all_users_token(reset_token_method) TmpUser.find_in_batches do |batch| puts "Processing batch starting with user ID: #{batch.first.id}" @@ -35,4 +40,9 @@ class TmpUser < ActiveRecord::Base write_new_token(:incoming_email_token) save!(validate: false) end + + def reset_rss_token! + write_new_token(:rss_token) + save!(validate: false) + end end diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index 1c44ed4b77c..e4be9a1aa70 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -7,24 +7,208 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" +<<<<<<< HEAD "PO-Revision-Date: 2017-05-09 13:44+0200\n" +======= +"PO-Revision-Date: 2017-06-07 12:17+0200\n" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 "Language-Team: German\n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" +<<<<<<< HEAD "Last-Translator: \n" "X-Generator: Poedit 2.0.1\n" msgid "ByAuthor|by" msgstr "Von" +======= +"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n" +"X-Generator: Poedit 2.0.2\n" + +msgid "About auto deploy" +msgstr "" + +msgid "Activity" +msgstr "" + +msgid "Add Changelog" +msgstr "" + +msgid "Add Contribution guide" +msgstr "" + +msgid "Add License" +msgstr "" + +msgid "Add an SSH key to your profile to pull or push via SSH." +msgstr "" + +msgid "Add new directory" +msgstr "" + +msgid "Archived project! Repository is read-only" +msgstr "" + +msgid "Branch" +msgid_plural "Branches" +msgstr[0] "" +msgstr[1] "" + +msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}" +msgstr "" + +msgid "Branches" +msgstr "" + +msgid "ByAuthor|by" +msgstr "Von" + +msgid "CI configuration" +msgstr "" + +msgid "Changelog" +msgstr "" + +msgid "Charts" +msgstr "" + +msgid "CiStatusLabel|canceled" +msgstr "" + +msgid "CiStatusLabel|created" +msgstr "" + +msgid "CiStatusLabel|failed" +msgstr "" + +msgid "CiStatusLabel|manual action" +msgstr "" + +msgid "CiStatusLabel|passed" +msgstr "" + +msgid "CiStatusLabel|passed with warnings" +msgstr "" + +msgid "CiStatusLabel|pending" +msgstr "" + +msgid "CiStatusLabel|skipped" +msgstr "" + +msgid "CiStatusLabel|waiting for manual action" +msgstr "" + +msgid "CiStatusText|blocked" +msgstr "" + +msgid "CiStatusText|canceled" +msgstr "" + +msgid "CiStatusText|created" +msgstr "" + +msgid "CiStatusText|failed" +msgstr "" + +msgid "CiStatusText|manual" +msgstr "" + +msgid "CiStatusText|passed" +msgstr "" + +msgid "CiStatusText|pending" +msgstr "" + +msgid "CiStatusText|skipped" +msgstr "" + +msgid "CiStatus|running" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "Commit" msgid_plural "Commits" msgstr[0] "Commit" msgstr[1] "Commits" +<<<<<<< HEAD +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht." + +msgid "CycleAnalyticsStage|Code" +msgstr "Code" + +msgid "CycleAnalyticsStage|Issue" +msgstr "Issue" + +msgid "CycleAnalyticsStage|Plan" +msgstr "Planung" + +msgid "CycleAnalyticsStage|Production" +msgstr "Produktiv" + +msgid "CycleAnalyticsStage|Review" +msgstr "Review" + +msgid "CycleAnalyticsStage|Staging" +msgstr "Staging" + +msgid "CycleAnalyticsStage|Test" +msgstr "Test" +======= +msgid "CommitMessage|Add %{file_name}" +msgstr "" + +msgid "Commits" +msgstr "Commits" + +msgid "Commits|History" +msgstr "Commits" + +msgid "Compare" +msgstr "" + +msgid "Contribution guide" +msgstr "" + +msgid "Contributors" +msgstr "" + +msgid "Copy URL to clipboard" +msgstr "" + +msgid "Copy commit SHA to clipboard" +msgstr "" + +msgid "Create New Directory" +msgstr "" + +msgid "Create directory" +msgstr "" + +msgid "Create empty bare repository" +msgstr "" + +msgid "Create merge request" +msgstr "" + +msgid "CreateNewFork|Fork" +msgstr "" + +msgid "Custom notification events" +msgstr "" + +msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}." +msgstr "" + +msgid "Cycle Analytics" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht." @@ -54,25 +238,117 @@ msgid_plural "Deploys" msgstr[0] "Deployment" msgstr[1] "Deployments" +<<<<<<< HEAD +msgid "FirstPushedBy|First" +msgstr "Erster" +======= +msgid "Directory name" +msgstr "" + +msgid "Don't show again" +msgstr "" + +msgid "Download tar" +msgstr "" + +msgid "Download tar.bz2" +msgstr "" + +msgid "Download tar.gz" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + +msgid "Download zip" +msgstr "" + +msgid "DownloadArtifacts|Download" +msgstr "" + +msgid "DownloadSource|Download" +msgstr "" + +msgid "Files" +msgstr "" + +msgid "Find by path" +msgstr "" + +msgid "Find file" +msgstr "" + msgid "FirstPushedBy|First" msgstr "Erster" msgid "FirstPushedBy|pushed by" msgstr "gepusht von" +<<<<<<< HEAD + +msgid "From issue creation until deploy to production" +msgstr "Vom Anlegen des Issues bis zum Produktivdeployment" +======= + +msgid "ForkedFromProjectPath|Forked from" +msgstr "" + +msgid "Forks" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "From issue creation until deploy to production" msgstr "Vom Anlegen des Issues bis zum Produktivdeployment" msgid "From merge request merge until deploy to production" msgstr "Vom Merge Request bis zum Produktivdeployment" +<<<<<<< HEAD msgid "Introducing Cycle Analytics" msgstr "Was sind Cycle Analytics?" +======= + +msgid "Go to your fork" +msgstr "" + +msgid "GoToYourFork|Fork" +msgstr "" + +msgid "Home" +msgstr "" + +msgid "Housekeeping successfully started" +msgstr "" + +msgid "Import repository" +msgstr "" + +msgid "Introducing Cycle Analytics" +msgstr "Was sind Cycle Analytics?" + +msgid "LFSStatus|Disabled" +msgstr "" + +msgid "LFSStatus|Enabled" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "Last %d day" msgid_plural "Last %d days" msgstr[0] "Letzter %d Tag" msgstr[1] "Letzten %d Tage" +<<<<<<< HEAD +======= + +msgid "Last Update" +msgstr "" + +msgid "Last commit" +msgstr "" + +msgid "Leave group" +msgstr "" + +msgid "Leave project" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "Limited to showing %d event at most" msgid_plural "Limited to showing %d events at most" @@ -81,23 +357,119 @@ msgstr[1] "Eingeschränkt auf maximal %d Ereignisse" msgid "Median" msgstr "Median" +<<<<<<< HEAD +======= + +msgid "MissingSSHKeyWarningLink|add an SSH key" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "New Issue" msgid_plural "New Issues" msgstr[0] "Neues Issue" msgstr[1] "Neue Issues" +<<<<<<< HEAD +msgid "Not available" +msgstr "Nicht verfügbar" +======= +msgid "New branch" +msgstr "" + +msgid "New directory" +msgstr "" + +msgid "New file" +msgstr "" + +msgid "New issue" +msgstr "Neues Issue" + +msgid "New merge request" +msgstr "" + +msgid "New snippet" +msgstr "" + +msgid "New tag" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + +msgid "No repository" +msgstr "" + msgid "Not available" msgstr "Nicht verfügbar" msgid "Not enough data" msgstr "Nicht genügend Daten" +<<<<<<< HEAD + +msgid "OpenedNDaysAgo|Opened" +msgstr "Erstellt" +======= + +msgid "Notification events" +msgstr "" + +msgid "NotificationEvent|Close issue" +msgstr "" + +msgid "NotificationEvent|Close merge request" +msgstr "" + +msgid "NotificationEvent|Failed pipeline" +msgstr "" + +msgid "NotificationEvent|Merge merge request" +msgstr "" + +msgid "NotificationEvent|New issue" +msgstr "" + +msgid "NotificationEvent|New merge request" +msgstr "" + +msgid "NotificationEvent|New note" +msgstr "" + +msgid "NotificationEvent|Reassign issue" +msgstr "" + +msgid "NotificationEvent|Reassign merge request" +msgstr "" + +msgid "NotificationEvent|Reopen issue" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + +msgid "NotificationEvent|Successful pipeline" +msgstr "" + +msgid "NotificationLevel|Custom" +msgstr "" + +msgid "NotificationLevel|Disabled" +msgstr "" + +msgid "NotificationLevel|Global" +msgstr "" + +msgid "NotificationLevel|On mention" +msgstr "" + +msgid "NotificationLevel|Participate" +msgstr "" + +msgid "NotificationLevel|Watch" +msgstr "" msgid "OpenedNDaysAgo|Opened" msgstr "Erstellt" msgid "Pipeline Health" msgstr "Pipeline Kennzahlen" +<<<<<<< HEAD msgid "ProjectLifecycle|Stage" msgstr "Phase" @@ -110,6 +482,77 @@ msgstr "Zugehörige Commits" msgid "Related Deployed Jobs" msgstr "Zugehörige Deploymentjobs" +======= + +msgid "Project '%{project_name}' queued for deletion." +msgstr "" + +msgid "Project '%{project_name}' was successfully created." +msgstr "" + +msgid "Project '%{project_name}' was successfully updated." +msgstr "" + +msgid "Project '%{project_name}' will be deleted." +msgstr "" + +msgid "Project access must be granted explicitly to each user." +msgstr "" + +msgid "Project export could not be deleted." +msgstr "" + +msgid "Project export has been deleted." +msgstr "" + +msgid "Project export link has expired. Please generate a new export from your project settings." +msgstr "" + +msgid "Project export started. A download link will be sent by email." +msgstr "" + +msgid "Project home" +msgstr "" + +msgid "ProjectFeature|Disabled" +msgstr "" + +msgid "ProjectFeature|Everyone with access" +msgstr "" + +msgid "ProjectFeature|Only team members" +msgstr "" + +msgid "ProjectFileTree|Name" +msgstr "" + +msgid "ProjectLastActivity|Never" +msgstr "" + +msgid "ProjectLifecycle|Stage" +msgstr "Phase" + +msgid "ProjectNetworkGraph|Graph" +msgstr "" + +msgid "Read more" +msgstr "Mehr" + +msgid "Readme" +msgstr "" + +msgid "RefSwitcher|Branches" +msgstr "" + +msgid "RefSwitcher|Tags" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + +msgid "Related Commits" +msgstr "Zugehörige Commits" + +msgid "Related Deployed Jobs" +msgstr "Zugehörige Deploymentjobs" msgid "Related Issues" msgstr "Zugehörige Issues" @@ -122,17 +565,79 @@ msgstr "Zugehörige Merge Requests" msgid "Related Merged Requests" msgstr "Zugehörige abgeschlossene Merge Requests" +<<<<<<< HEAD +======= + +msgid "Remind later" +msgstr "" + +msgid "Remove project" +msgstr "" + +msgid "Request Access" +msgstr "" + +msgid "Search branches and tags" +msgstr "" + +msgid "Select Archive Format" +msgstr "" + +msgid "Set a password on your account to pull or push via %{protocol}" +msgstr "" + +msgid "Set up CI" +msgstr "" + +msgid "Set up Koding" +msgstr "" + +msgid "Set up auto deploy" +msgstr "" + +msgid "SetPasswordToCloneLink|set a password" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "Showing %d event" msgid_plural "Showing %d events" msgstr[0] "Zeige %d Ereignis" msgstr[1] "Zeige %d Ereignisse" +<<<<<<< HEAD + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." +msgstr "Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt." +======= + +msgid "Source code" +msgstr "" + +msgid "StarProject|Star" +msgstr "" + +msgid "Switch branch/tag" +msgstr "" + +msgid "Tag" +msgid_plural "Tags" +msgstr[0] "" +msgstr[1] "" + +msgid "Tags" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt." msgid "The collection of events added to the data gathered for that stage." msgstr "Ereignisse, die für diese Phase ausgewertet wurden." +<<<<<<< HEAD +======= + +msgid "The fork relationship has been removed." +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen." @@ -145,6 +650,24 @@ msgstr "Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushe msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." msgstr "Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier." +<<<<<<< HEAD + +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." +msgstr "Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt." + +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." +msgstr "Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt." +======= + +msgid "The project can be accessed by any logged in user." +msgstr "" + +msgid "The project can be accessed without any authentication." +msgstr "" + +msgid "The repository for this project does not exist." +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt." @@ -160,6 +683,12 @@ msgstr "Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde." msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6." +<<<<<<< HEAD +======= + +msgid "This means you can not push code until you create an empty repository or import existing one." +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "Time before an issue gets scheduled" msgstr "Zeit bis ein Issue geplant wird" @@ -172,6 +701,132 @@ msgstr "Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests" msgid "Time until first merge request" msgstr "Zeit bis zum ersten Merge Request" +<<<<<<< HEAD +======= + +msgid "Timeago|%s days ago" +msgstr "" + +msgid "Timeago|%s days remaining" +msgstr "" + +msgid "Timeago|%s hours remaining" +msgstr "" + +msgid "Timeago|%s minutes ago" +msgstr "" + +msgid "Timeago|%s minutes remaining" +msgstr "" + +msgid "Timeago|%s months ago" +msgstr "" + +msgid "Timeago|%s months remaining" +msgstr "" + +msgid "Timeago|%s seconds remaining" +msgstr "" + +msgid "Timeago|%s weeks ago" +msgstr "" + +msgid "Timeago|%s weeks remaining" +msgstr "" + +msgid "Timeago|%s years ago" +msgstr "" + +msgid "Timeago|%s years remaining" +msgstr "" + +msgid "Timeago|1 day remaining" +msgstr "" + +msgid "Timeago|1 hour remaining" +msgstr "" + +msgid "Timeago|1 minute remaining" +msgstr "" + +msgid "Timeago|1 month remaining" +msgstr "" + +msgid "Timeago|1 week remaining" +msgstr "" + +msgid "Timeago|1 year remaining" +msgstr "" + +msgid "Timeago|Past due" +msgstr "" + +msgid "Timeago|a day ago" +msgstr "" + +msgid "Timeago|a month ago" +msgstr "" + +msgid "Timeago|a week ago" +msgstr "" + +msgid "Timeago|a while" +msgstr "" + +msgid "Timeago|a year ago" +msgstr "" + +msgid "Timeago|about %s hours ago" +msgstr "" + +msgid "Timeago|about a minute ago" +msgstr "" + +msgid "Timeago|about an hour ago" +msgstr "" + +msgid "Timeago|in %s days" +msgstr "" + +msgid "Timeago|in %s hours" +msgstr "" + +msgid "Timeago|in %s minutes" +msgstr "" + +msgid "Timeago|in %s months" +msgstr "" + +msgid "Timeago|in %s seconds" +msgstr "" + +msgid "Timeago|in %s weeks" +msgstr "" + +msgid "Timeago|in %s years" +msgstr "" + +msgid "Timeago|in 1 day" +msgstr "" + +msgid "Timeago|in 1 hour" +msgstr "" + +msgid "Timeago|in 1 minute" +msgstr "" + +msgid "Timeago|in 1 month" +msgstr "" + +msgid "Timeago|in 1 week" +msgstr "" + +msgid "Timeago|in 1 year" +msgstr "" + +msgid "Timeago|less than a minute ago" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 msgid "Time|hr" msgid_plural "Time|hrs" @@ -191,17 +846,104 @@ msgstr "Gesamtzeit" msgid "Total test time for all commits/merges" msgstr "Gesamte Testlaufzeit für alle Commits/Merges" +<<<<<<< HEAD + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator." +======= + +msgid "Unstar" +msgstr "" + +msgid "Upload New File" +msgstr "" + +msgid "Upload file" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + +msgid "Use your global notification setting" +msgstr "" + +msgid "VisibilityLevel|Internal" +msgstr "" + +msgid "VisibilityLevel|Private" +msgstr "" + +msgid "VisibilityLevel|Public" +msgstr "" msgid "Want to see the data? Please ask an administrator for access." msgstr "Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator." msgid "We don't have enough data to show this stage." msgstr "Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen." +<<<<<<< HEAD + +msgid "You need permission." +msgstr "Sie benötigen Zugriffsrechte." +======= + +msgid "Withdraw Access Request" +msgstr "" + +msgid "" +"You are going to remove %{project_name_with_namespace}.\n" +"Removed project CANNOT be restored!\n" +"Are you ABSOLUTELY sure?" +msgstr "" + +msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?" +msgstr "" + +msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" +msgstr "" + +msgid "You can only add files when you are on a branch" +msgstr "" + +msgid "You must sign in to star a project" +msgstr "" msgid "You need permission." msgstr "Sie benötigen Zugriffsrechte." +msgid "You will not get any notifications via email" +msgstr "" + +msgid "You will only receive notifications for the events you choose" +msgstr "" + +msgid "You will only receive notifications for threads you have participated in" +msgstr "" + +msgid "You will receive notifications for any activity" +msgstr "" + +msgid "You will receive notifications only for comments in which you were @mentioned" +msgstr "" + +msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account" +msgstr "" + +msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile" +msgstr "" + +msgid "Your name" +msgstr "" + +msgid "committed" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + msgid "day" msgid_plural "days" msgstr[0] "Tag" msgstr[1] "Tage" +<<<<<<< HEAD +======= + +msgid "notification emails" +msgstr "" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index a43bafbbe28..fca2bc15b1f 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -7,24 +7,170 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-04-12 22:36-0500\n" -"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"PO-Revision-Date: 2017-06-07 12:14+0200\n" "Language-Team: English\n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"\n" +"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n" +"X-Generator: Poedit 2.0.2\n" + +msgid "About auto deploy" +msgstr "" + +msgid "Activity" +msgstr "" + +msgid "Add Changelog" +msgstr "" + +msgid "Add Contribution guide" +msgstr "" + +msgid "Add License" +msgstr "" + +msgid "Add an SSH key to your profile to pull or push via SSH." +msgstr "" + +msgid "Add new directory" +msgstr "" + +msgid "Archived project! Repository is read-only" +msgstr "" + +msgid "Branch" +msgid_plural "Branches" +msgstr[0] "" +msgstr[1] "" + +msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}" +msgstr "" + +msgid "Branches" +msgstr "" msgid "ByAuthor|by" msgstr "" +msgid "CI configuration" +msgstr "" + +msgid "Changelog" +msgstr "" + +msgid "Charts" +msgstr "" + +msgid "CiStatusLabel|canceled" +msgstr "" + +msgid "CiStatusLabel|created" +msgstr "" + +msgid "CiStatusLabel|failed" +msgstr "" + +msgid "CiStatusLabel|manual action" +msgstr "" + +msgid "CiStatusLabel|passed" +msgstr "" + +msgid "CiStatusLabel|passed with warnings" +msgstr "" + +msgid "CiStatusLabel|pending" +msgstr "" + +msgid "CiStatusLabel|skipped" +msgstr "" + +msgid "CiStatusLabel|waiting for manual action" +msgstr "" + +msgid "CiStatusText|blocked" +msgstr "" + +msgid "CiStatusText|canceled" +msgstr "" + +msgid "CiStatusText|created" +msgstr "" + +msgid "CiStatusText|failed" +msgstr "" + +msgid "CiStatusText|manual" +msgstr "" + +msgid "CiStatusText|passed" +msgstr "" + +msgid "CiStatusText|pending" +msgstr "" + +msgid "CiStatusText|skipped" +msgstr "" + +msgid "CiStatus|running" +msgstr "" + msgid "Commit" msgid_plural "Commits" msgstr[0] "" msgstr[1] "" +msgid "CommitMessage|Add %{file_name}" +msgstr "" + +msgid "Commits" +msgstr "" + +msgid "Commits|History" +msgstr "" + +msgid "Compare" +msgstr "" + +msgid "Contribution guide" +msgstr "" + +msgid "Contributors" +msgstr "" + +msgid "Copy URL to clipboard" +msgstr "" + +msgid "Copy commit SHA to clipboard" +msgstr "" + +msgid "Create New Directory" +msgstr "" + +msgid "Create directory" +msgstr "" + +msgid "Create empty bare repository" +msgstr "" + +msgid "Create merge request" +msgstr "" + +msgid "CreateNewFork|Fork" +msgstr "" + +msgid "Custom notification events" +msgstr "" + +msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}." +msgstr "" + +msgid "Cycle Analytics" +msgstr "" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" @@ -54,26 +200,98 @@ msgid_plural "Deploys" msgstr[0] "" msgstr[1] "" +msgid "Directory name" +msgstr "" + +msgid "Don't show again" +msgstr "" + +msgid "Download tar" +msgstr "" + +msgid "Download tar.bz2" +msgstr "" + +msgid "Download tar.gz" +msgstr "" + +msgid "Download zip" +msgstr "" + +msgid "DownloadArtifacts|Download" +msgstr "" + +msgid "DownloadSource|Download" +msgstr "" + +msgid "Files" +msgstr "" + +msgid "Find by path" +msgstr "" + +msgid "Find file" +msgstr "" + msgid "FirstPushedBy|First" msgstr "" msgid "FirstPushedBy|pushed by" msgstr "" +msgid "ForkedFromProjectPath|Forked from" +msgstr "" + +msgid "Forks" +msgstr "" + msgid "From issue creation until deploy to production" msgstr "" msgid "From merge request merge until deploy to production" msgstr "" +msgid "Go to your fork" +msgstr "" + +msgid "GoToYourFork|Fork" +msgstr "" + +msgid "Home" +msgstr "" + +msgid "Housekeeping successfully started" +msgstr "" + +msgid "Import repository" +msgstr "" + msgid "Introducing Cycle Analytics" msgstr "" +msgid "LFSStatus|Disabled" +msgstr "" + +msgid "LFSStatus|Enabled" +msgstr "" + msgid "Last %d day" msgid_plural "Last %d days" msgstr[0] "" msgstr[1] "" +msgid "Last Update" +msgstr "" + +msgid "Last commit" +msgstr "" + +msgid "Leave group" +msgstr "" + +msgid "Leave project" +msgstr "" + msgid "Limited to showing %d event at most" msgid_plural "Limited to showing %d events at most" msgstr[0] "" @@ -82,29 +300,167 @@ msgstr[1] "" msgid "Median" msgstr "" +msgid "MissingSSHKeyWarningLink|add an SSH key" +msgstr "" + msgid "New Issue" msgid_plural "New Issues" msgstr[0] "" msgstr[1] "" +msgid "New branch" +msgstr "" + +msgid "New directory" +msgstr "" + +msgid "New file" +msgstr "" + +msgid "New issue" +msgstr "" + +msgid "New merge request" +msgstr "" + +msgid "New snippet" +msgstr "" + +msgid "New tag" +msgstr "" + +msgid "No repository" +msgstr "" + msgid "Not available" msgstr "" msgid "Not enough data" msgstr "" +msgid "Notification events" +msgstr "" + +msgid "NotificationEvent|Close issue" +msgstr "" + +msgid "NotificationEvent|Close merge request" +msgstr "" + +msgid "NotificationEvent|Failed pipeline" +msgstr "" + +msgid "NotificationEvent|Merge merge request" +msgstr "" + +msgid "NotificationEvent|New issue" +msgstr "" + +msgid "NotificationEvent|New merge request" +msgstr "" + +msgid "NotificationEvent|New note" +msgstr "" + +msgid "NotificationEvent|Reassign issue" +msgstr "" + +msgid "NotificationEvent|Reassign merge request" +msgstr "" + +msgid "NotificationEvent|Reopen issue" +msgstr "" + +msgid "NotificationEvent|Successful pipeline" +msgstr "" + +msgid "NotificationLevel|Custom" +msgstr "" + +msgid "NotificationLevel|Disabled" +msgstr "" + +msgid "NotificationLevel|Global" +msgstr "" + +msgid "NotificationLevel|On mention" +msgstr "" + +msgid "NotificationLevel|Participate" +msgstr "" + +msgid "NotificationLevel|Watch" +msgstr "" + msgid "OpenedNDaysAgo|Opened" msgstr "" msgid "Pipeline Health" msgstr "" +msgid "Project '%{project_name}' queued for deletion." +msgstr "" + +msgid "Project '%{project_name}' was successfully created." +msgstr "" + +msgid "Project '%{project_name}' was successfully updated." +msgstr "" + +msgid "Project '%{project_name}' will be deleted." +msgstr "" + +msgid "Project access must be granted explicitly to each user." +msgstr "" + +msgid "Project export could not be deleted." +msgstr "" + +msgid "Project export has been deleted." +msgstr "" + +msgid "Project export link has expired. Please generate a new export from your project settings." +msgstr "" + +msgid "Project export started. A download link will be sent by email." +msgstr "" + +msgid "Project home" +msgstr "" + +msgid "ProjectFeature|Disabled" +msgstr "" + +msgid "ProjectFeature|Everyone with access" +msgstr "" + +msgid "ProjectFeature|Only team members" +msgstr "" + +msgid "ProjectFileTree|Name" +msgstr "" + +msgid "ProjectLastActivity|Never" +msgstr "" + msgid "ProjectLifecycle|Stage" msgstr "" +msgid "ProjectNetworkGraph|Graph" +msgstr "" + msgid "Read more" msgstr "" +msgid "Readme" +msgstr "" + +msgid "RefSwitcher|Branches" +msgstr "" + +msgid "RefSwitcher|Tags" +msgstr "" + msgid "Related Commits" msgstr "" @@ -123,17 +479,67 @@ msgstr "" msgid "Related Merged Requests" msgstr "" +msgid "Remind later" +msgstr "" + +msgid "Remove project" +msgstr "" + +msgid "Request Access" +msgstr "" + +msgid "Search branches and tags" +msgstr "" + +msgid "Select Archive Format" +msgstr "" + +msgid "Set a password on your account to pull or push via %{protocol}" +msgstr "" + +msgid "Set up CI" +msgstr "" + +msgid "Set up Koding" +msgstr "" + +msgid "Set up auto deploy" +msgstr "" + +msgid "SetPasswordToCloneLink|set a password" +msgstr "" + msgid "Showing %d event" msgid_plural "Showing %d events" msgstr[0] "" msgstr[1] "" +msgid "Source code" +msgstr "" + +msgid "StarProject|Star" +msgstr "" + +msgid "Switch branch/tag" +msgstr "" + +msgid "Tag" +msgid_plural "Tags" +msgstr[0] "" +msgstr[1] "" + +msgid "Tags" +msgstr "" + msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" msgid "The collection of events added to the data gathered for that stage." msgstr "" +msgid "The fork relationship has been removed." +msgstr "" + msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" @@ -146,6 +552,15 @@ msgstr "" msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." msgstr "" +msgid "The project can be accessed by any logged in user." +msgstr "" + +msgid "The project can be accessed without any authentication." +msgstr "" + +msgid "The repository for this project does not exist." +msgstr "" + msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "" @@ -161,6 +576,9 @@ msgstr "" msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "" +msgid "This means you can not push code until you create an empty repository or import existing one." +msgstr "" + msgid "Time before an issue gets scheduled" msgstr "" @@ -173,6 +591,129 @@ msgstr "" msgid "Time until first merge request" msgstr "" +msgid "Timeago|%s days ago" +msgstr "" + +msgid "Timeago|%s days remaining" +msgstr "" + +msgid "Timeago|%s hours remaining" +msgstr "" + +msgid "Timeago|%s minutes ago" +msgstr "" + +msgid "Timeago|%s minutes remaining" +msgstr "" + +msgid "Timeago|%s months ago" +msgstr "" + +msgid "Timeago|%s months remaining" +msgstr "" + +msgid "Timeago|%s seconds remaining" +msgstr "" + +msgid "Timeago|%s weeks ago" +msgstr "" + +msgid "Timeago|%s weeks remaining" +msgstr "" + +msgid "Timeago|%s years ago" +msgstr "" + +msgid "Timeago|%s years remaining" +msgstr "" + +msgid "Timeago|1 day remaining" +msgstr "" + +msgid "Timeago|1 hour remaining" +msgstr "" + +msgid "Timeago|1 minute remaining" +msgstr "" + +msgid "Timeago|1 month remaining" +msgstr "" + +msgid "Timeago|1 week remaining" +msgstr "" + +msgid "Timeago|1 year remaining" +msgstr "" + +msgid "Timeago|Past due" +msgstr "" + +msgid "Timeago|a day ago" +msgstr "" + +msgid "Timeago|a month ago" +msgstr "" + +msgid "Timeago|a week ago" +msgstr "" + +msgid "Timeago|a while" +msgstr "" + +msgid "Timeago|a year ago" +msgstr "" + +msgid "Timeago|about %s hours ago" +msgstr "" + +msgid "Timeago|about a minute ago" +msgstr "" + +msgid "Timeago|about an hour ago" +msgstr "" + +msgid "Timeago|in %s days" +msgstr "" + +msgid "Timeago|in %s hours" +msgstr "" + +msgid "Timeago|in %s minutes" +msgstr "" + +msgid "Timeago|in %s months" +msgstr "" + +msgid "Timeago|in %s seconds" +msgstr "" + +msgid "Timeago|in %s weeks" +msgstr "" + +msgid "Timeago|in %s years" +msgstr "" + +msgid "Timeago|in 1 day" +msgstr "" + +msgid "Timeago|in 1 hour" +msgstr "" + +msgid "Timeago|in 1 minute" +msgstr "" + +msgid "Timeago|in 1 month" +msgstr "" + +msgid "Timeago|in 1 week" +msgstr "" + +msgid "Timeago|in 1 year" +msgstr "" + +msgid "Timeago|less than a minute ago" +msgstr "" + msgid "Time|hr" msgid_plural "Time|hrs" msgstr[0] "" @@ -192,16 +733,88 @@ msgstr "" msgid "Total test time for all commits/merges" msgstr "" +msgid "Unstar" +msgstr "" + +msgid "Upload New File" +msgstr "" + +msgid "Upload file" +msgstr "" + +msgid "Use your global notification setting" +msgstr "" + +msgid "VisibilityLevel|Internal" +msgstr "" + +msgid "VisibilityLevel|Private" +msgstr "" + +msgid "VisibilityLevel|Public" +msgstr "" + msgid "Want to see the data? Please ask an administrator for access." msgstr "" msgid "We don't have enough data to show this stage." msgstr "" +msgid "Withdraw Access Request" +msgstr "" + +msgid "" +"You are going to remove %{project_name_with_namespace}.\n" +"Removed project CANNOT be restored!\n" +"Are you ABSOLUTELY sure?" +msgstr "" + +msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?" +msgstr "" + +msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" +msgstr "" + +msgid "You can only add files when you are on a branch" +msgstr "" + +msgid "You must sign in to star a project" +msgstr "" + msgid "You need permission." msgstr "" +msgid "You will not get any notifications via email" +msgstr "" + +msgid "You will only receive notifications for the events you choose" +msgstr "" + +msgid "You will only receive notifications for threads you have participated in" +msgstr "" + +msgid "You will receive notifications for any activity" +msgstr "" + +msgid "You will receive notifications only for comments in which you were @mentioned" +msgstr "" + +msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account" +msgstr "" + +msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile" +msgstr "" + +msgid "Your name" +msgstr "" + +msgid "committed" +msgstr "" + msgid "day" msgid_plural "days" msgstr[0] "" msgstr[1] "" + +msgid "notification emails" +msgstr "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index c14ddd3b94c..78d28d69885 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,24 +7,170 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"PO-Revision-Date: 2017-06-07 12:29-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"Last-Translator: \n" -"X-Generator: Poedit 2.0.1\n" +"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n" +"X-Generator: Poedit 2.0.2\n" + +msgid "About auto deploy" +msgstr "Acerca del auto despliegue" + +msgid "Activity" +msgstr "Actividad" + +msgid "Add Changelog" +msgstr "Agregar Changelog" + +msgid "Add Contribution guide" +msgstr "Agregar guía de contribución" + +msgid "Add License" +msgstr "Agregar Licencia" + +msgid "Add an SSH key to your profile to pull or push via SSH." +msgstr "Agregar una clave SSH a tu perfil para actualizar o enviar a través de SSH." + +msgid "Add new directory" +msgstr "Agregar nuevo directorio" + +msgid "Archived project! Repository is read-only" +msgstr "¡Proyecto archivado! El repositorio es de sólo lectura" + +msgid "Branch" +msgid_plural "Branches" +msgstr[0] "Rama" +msgstr[1] "Ramas" + +msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}" +msgstr "La rama <strong>%{branch_name}</strong> fue creada. Para configurar el auto despliegue, escoge una plantilla Yaml para GitLab CI y envía tus cambios. %{link_to_autodeploy_doc}" + +msgid "Branches" +msgstr "Ramas" msgid "ByAuthor|by" msgstr "por" +msgid "CI configuration" +msgstr "Configuración de CI" + +msgid "Changelog" +msgstr "Changelog" + +msgid "Charts" +msgstr "Gráficos" + +msgid "CiStatusLabel|canceled" +msgstr "cancelado" + +msgid "CiStatusLabel|created" +msgstr "creado" + +msgid "CiStatusLabel|failed" +msgstr "fallado" + +msgid "CiStatusLabel|manual action" +msgstr "acción manual" + +msgid "CiStatusLabel|passed" +msgstr "pasó" + +msgid "CiStatusLabel|passed with warnings" +msgstr "pasó con advertencias" + +msgid "CiStatusLabel|pending" +msgstr "pendiente" + +msgid "CiStatusLabel|skipped" +msgstr "omitido" + +msgid "CiStatusLabel|waiting for manual action" +msgstr "esperando acción manual" + +msgid "CiStatusText|blocked" +msgstr "bloqueado" + +msgid "CiStatusText|canceled" +msgstr "cancelado" + +msgid "CiStatusText|created" +msgstr "creado" + +msgid "CiStatusText|failed" +msgstr "fallado" + +msgid "CiStatusText|manual" +msgstr "manual" + +msgid "CiStatusText|passed" +msgstr "pasó" + +msgid "CiStatusText|pending" +msgstr "pendiente" + +msgid "CiStatusText|skipped" +msgstr "omitido" + +msgid "CiStatus|running" +msgstr "en ejecución" + msgid "Commit" msgid_plural "Commits" msgstr[0] "Cambio" msgstr[1] "Cambios" +msgid "CommitMessage|Add %{file_name}" +msgstr "Agregar %{file_name}" + +msgid "Commits" +msgstr "Cambios" + +msgid "Commits|History" +msgstr "Historial" + +msgid "Compare" +msgstr "Comparar" + +msgid "Contribution guide" +msgstr "Guía de contribución" + +msgid "Contributors" +msgstr "Contribuidores" + +msgid "Copy URL to clipboard" +msgstr "Copiar URL al portapapeles" + +msgid "Copy commit SHA to clipboard" +msgstr "Copiar SHA del cambio al portapapeles" + +msgid "Create New Directory" +msgstr "Crear Nuevo Directorio" + +msgid "Create directory" +msgstr "Crear directorio" + +msgid "Create empty bare repository" +msgstr "Crear repositorio vacío" + +msgid "Create merge request" +msgstr "Crear solicitud de fusión" + +msgid "CreateNewFork|Fork" +msgstr "Bifurcar" + +msgid "Custom notification events" +msgstr "Eventos de notificaciones personalizadas" + +msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}." +msgstr "Los niveles de notificación personalizados son los mismos que los niveles participantes. Con los niveles de notificación personalizados, también recibirá notificaciones para eventos seleccionados. Para obtener más información, consulte %{notification_link}." + +msgid "Cycle Analytics" +msgstr "Cycle Analytics" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." @@ -43,7 +189,6 @@ msgstr "Producción" msgid "CycleAnalyticsStage|Review" msgstr "Revisión" -#, fuzzy msgid "CycleAnalyticsStage|Staging" msgstr "Puesta en escena" @@ -55,26 +200,98 @@ msgid_plural "Deploys" msgstr[0] "Despliegue" msgstr[1] "Despliegues" +msgid "Directory name" +msgstr "Nombre del directorio" + +msgid "Don't show again" +msgstr "No mostrar de nuevo" + +msgid "Download tar" +msgstr "Descargar tar" + +msgid "Download tar.bz2" +msgstr "Descargar tar.bz2" + +msgid "Download tar.gz" +msgstr "Descargar tar.gz" + +msgid "Download zip" +msgstr "Descargar zip" + +msgid "DownloadArtifacts|Download" +msgstr "Descargar" + +msgid "DownloadSource|Download" +msgstr "Descargar" + +msgid "Files" +msgstr "Archivos" + +msgid "Find by path" +msgstr "Buscar por ruta" + +msgid "Find file" +msgstr "Buscar archivo" + msgid "FirstPushedBy|First" msgstr "Primer" msgid "FirstPushedBy|pushed by" msgstr "enviado por" +msgid "ForkedFromProjectPath|Forked from" +msgstr "Bifurcado de" + +msgid "Forks" +msgstr "Bifurcaciones" + msgid "From issue creation until deploy to production" msgstr "Desde la creación de la incidencia hasta el despliegue a producción" msgid "From merge request merge until deploy to production" msgstr "Desde la integración de la solicitud de fusión hasta el despliegue a producción" +msgid "Go to your fork" +msgstr "Ir a tu bifurcación" + +msgid "GoToYourFork|Fork" +msgstr "Bifurcación" + +msgid "Home" +msgstr "Inicio" + +msgid "Housekeeping successfully started" +msgstr "Servicio de limpieza iniciado con éxito" + +msgid "Import repository" +msgstr "Importar repositorio" + msgid "Introducing Cycle Analytics" msgstr "Introducción a Cycle Analytics" +msgid "LFSStatus|Disabled" +msgstr "Deshabilitado" + +msgid "LFSStatus|Enabled" +msgstr "Habilitado" + msgid "Last %d day" msgid_plural "Last %d days" msgstr[0] "Último %d día" msgstr[1] "Últimos %d días" +msgid "Last Update" +msgstr "Última actualización" + +msgid "Last commit" +msgstr "Último cambio" + +msgid "Leave group" +msgstr "Abandonar grupo" + +msgid "Leave project" +msgstr "Abandonar proyecto" + msgid "Limited to showing %d event at most" msgid_plural "Limited to showing %d events at most" msgstr[0] "Limitado a mostrar máximo %d evento" @@ -83,29 +300,167 @@ msgstr[1] "Limitado a mostrar máximo %d eventos" msgid "Median" msgstr "Mediana" +msgid "MissingSSHKeyWarningLink|add an SSH key" +msgstr "agregar una clave SSH" + msgid "New Issue" msgid_plural "New Issues" msgstr[0] "Nueva incidencia" msgstr[1] "Nuevas incidencias" +msgid "New branch" +msgstr "Nueva rama" + +msgid "New directory" +msgstr "Nuevo directorio" + +msgid "New file" +msgstr "Nuevo archivo" + +msgid "New issue" +msgstr "Nueva incidencia" + +msgid "New merge request" +msgstr "Nueva solicitud de fusión" + +msgid "New snippet" +msgstr "Nuevo fragmento de código" + +msgid "New tag" +msgstr "Nueva etiqueta" + +msgid "No repository" +msgstr "No hay repositorio" + msgid "Not available" msgstr "No disponible" msgid "Not enough data" msgstr "No hay suficientes datos" +msgid "Notification events" +msgstr "Eventos de notificación" + +msgid "NotificationEvent|Close issue" +msgstr "Cerrar incidencia" + +msgid "NotificationEvent|Close merge request" +msgstr "Cerrar solicitud de fusión" + +msgid "NotificationEvent|Failed pipeline" +msgstr "Pipeline fallido" + +msgid "NotificationEvent|Merge merge request" +msgstr "Integrar solicitud de fusión" + +msgid "NotificationEvent|New issue" +msgstr "Nueva incidencia" + +msgid "NotificationEvent|New merge request" +msgstr "Nueva solicitud de fusión" + +msgid "NotificationEvent|New note" +msgstr "Nueva nota" + +msgid "NotificationEvent|Reassign issue" +msgstr "Reasignar incidencia" + +msgid "NotificationEvent|Reassign merge request" +msgstr "Reasignar solicitud de fusión" + +msgid "NotificationEvent|Reopen issue" +msgstr "Reabrir incidencia" + +msgid "NotificationEvent|Successful pipeline" +msgstr "Pipeline exitoso" + +msgid "NotificationLevel|Custom" +msgstr "Personalizado" + +msgid "NotificationLevel|Disabled" +msgstr "Deshabilitado" + +msgid "NotificationLevel|Global" +msgstr "Global" + +msgid "NotificationLevel|On mention" +msgstr "Cuando me mencionan" + +msgid "NotificationLevel|Participate" +msgstr "Participación" + +msgid "NotificationLevel|Watch" +msgstr "Vigilancia" + msgid "OpenedNDaysAgo|Opened" msgstr "Abierto" msgid "Pipeline Health" msgstr "Estado del Pipeline" +msgid "Project '%{project_name}' queued for deletion." +msgstr "Proyecto ‘%{project_name}’ en cola para eliminación." + +msgid "Project '%{project_name}' was successfully created." +msgstr "Proyecto ‘%{project_name}’ fue creado satisfactoriamente." + +msgid "Project '%{project_name}' was successfully updated." +msgstr "Proyecto ‘%{project_name}’ fue actualizado satisfactoriamente." + +msgid "Project '%{project_name}' will be deleted." +msgstr "Proyecto ‘%{project_name}’ será eliminado." + +msgid "Project access must be granted explicitly to each user." +msgstr "El acceso al proyecto debe concederse explícitamente a cada usuario." + +msgid "Project export could not be deleted." +msgstr "No se pudo eliminar la exportación del proyecto." + +msgid "Project export has been deleted." +msgstr "La exportación del proyecto ha sido eliminada." + +msgid "Project export link has expired. Please generate a new export from your project settings." +msgstr "El enlace de exportación del proyecto ha caducado. Por favor, genera una nueva exportación desde la configuración del proyecto." + +msgid "Project export started. A download link will be sent by email." +msgstr "Se inició la exportación del proyecto. Se enviará un enlace de descarga por correo electrónico." + +msgid "Project home" +msgstr "Inicio del proyecto" + +msgid "ProjectFeature|Disabled" +msgstr "Deshabilitada" + +msgid "ProjectFeature|Everyone with access" +msgstr "Todos con acceso" + +msgid "ProjectFeature|Only team members" +msgstr "Solo miembros del equipo" + +msgid "ProjectFileTree|Name" +msgstr "Nombre" + +msgid "ProjectLastActivity|Never" +msgstr "Nunca" + msgid "ProjectLifecycle|Stage" msgstr "Etapa" +msgid "ProjectNetworkGraph|Graph" +msgstr "Historial gráfico" + msgid "Read more" msgstr "Leer más" +msgid "Readme" +msgstr "Readme" + +msgid "RefSwitcher|Branches" +msgstr "Ramas" + +msgid "RefSwitcher|Tags" +msgstr "Etiquetas" + msgid "Related Commits" msgstr "Cambios Relacionados" @@ -124,17 +479,67 @@ msgstr "Solicitudes de fusión Relacionadas" msgid "Related Merged Requests" msgstr "Solicitudes de fusión Relacionadas" +msgid "Remind later" +msgstr "Recordar después" + +msgid "Remove project" +msgstr "Eliminar proyecto" + +msgid "Request Access" +msgstr "Solicitar acceso" + +msgid "Search branches and tags" +msgstr "Buscar ramas y etiquetas" + +msgid "Select Archive Format" +msgstr "Seleccionar formato de archivo" + +msgid "Set a password on your account to pull or push via %{protocol}" +msgstr "Establezca una contraseña en su cuenta para actualizar o enviar a través de% {protocol}" + +msgid "Set up CI" +msgstr "Configurar CI" + +msgid "Set up Koding" +msgstr "Configurar Koding" + +msgid "Set up auto deploy" +msgstr "Configurar auto despliegue" + +msgid "SetPasswordToCloneLink|set a password" +msgstr "establecer una contraseña" + msgid "Showing %d event" msgid_plural "Showing %d events" msgstr[0] "Mostrando %d evento" msgstr[1] "Mostrando %d eventos" +msgid "Source code" +msgstr "Código fuente" + +msgid "StarProject|Star" +msgstr "Destacar" + +msgid "Switch branch/tag" +msgstr "Cambiar rama/etiqueta" + +msgid "Tag" +msgid_plural "Tags" +msgstr[0] "Etiqueta" +msgstr[1] "Etiquetas" + +msgid "Tags" +msgstr "Etiquetas" + msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." -msgstr "La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión." +msgstr "La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión." msgid "The collection of events added to the data gathered for that stage." msgstr "La colección de eventos agregados a los datos recopilados para esa etapa." +msgid "The fork relationship has been removed." +msgstr "La relación con la bifurcación se ha eliminado." + msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." @@ -147,6 +552,15 @@ msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hast msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción." +msgid "The project can be accessed by any logged in user." +msgstr "El proyecto puede ser accedido por cualquier usuario conectado." + +msgid "The project can be accessed without any authentication." +msgstr "El proyecto puede accederse sin ninguna autenticación." + +msgid "The repository for this project does not exist." +msgstr "El repositorio para este proyecto no existe." + msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión." @@ -162,6 +576,9 @@ msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa." msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6." +msgid "This means you can not push code until you create an empty repository or import existing one." +msgstr "Esto significa que no puede enviar código hasta que cree un repositorio vacío o importe uno existente." + msgid "Time before an issue gets scheduled" msgstr "Tiempo antes de que una incidencia sea programada" @@ -174,6 +591,129 @@ msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o msgid "Time until first merge request" msgstr "Tiempo hasta la primera solicitud de fusión" +msgid "Timeago|%s days ago" +msgstr "hace %s días" + +msgid "Timeago|%s days remaining" +msgstr "%s días restantes" + +msgid "Timeago|%s hours remaining" +msgstr "%s horas restantes" + +msgid "Timeago|%s minutes ago" +msgstr "hace %s minutos" + +msgid "Timeago|%s minutes remaining" +msgstr "%s minutos restantes" + +msgid "Timeago|%s months ago" +msgstr "hace %s meses" + +msgid "Timeago|%s months remaining" +msgstr "%s meses restantes" + +msgid "Timeago|%s seconds remaining" +msgstr "%s segundos restantes" + +msgid "Timeago|%s weeks ago" +msgstr "hace %s semanas" + +msgid "Timeago|%s weeks remaining" +msgstr "%s semanas restantes" + +msgid "Timeago|%s years ago" +msgstr "hace %s años" + +msgid "Timeago|%s years remaining" +msgstr "%s años restantes" + +msgid "Timeago|1 day remaining" +msgstr "1 día restante" + +msgid "Timeago|1 hour remaining" +msgstr "1 hora restante" + +msgid "Timeago|1 minute remaining" +msgstr "1 minuto restante" + +msgid "Timeago|1 month remaining" +msgstr "1 mes restante" + +msgid "Timeago|1 week remaining" +msgstr "1 semana restante" + +msgid "Timeago|1 year remaining" +msgstr "1 año restante" + +msgid "Timeago|Past due" +msgstr "Atrasado" + +msgid "Timeago|a day ago" +msgstr "hace un día" + +msgid "Timeago|a month ago" +msgstr "hace 1 mes" + +msgid "Timeago|a week ago" +msgstr "hace 1 semana" + +msgid "Timeago|a while" +msgstr "hace un momento" + +msgid "Timeago|a year ago" +msgstr "hace 1 año" + +msgid "Timeago|about %s hours ago" +msgstr "hace alrededor de %s horas" + +msgid "Timeago|about a minute ago" +msgstr "hace alrededor de 1 minuto" + +msgid "Timeago|about an hour ago" +msgstr "hace alrededor de 1 hora" + +msgid "Timeago|in %s days" +msgstr "en %s días" + +msgid "Timeago|in %s hours" +msgstr "en %s horas" + +msgid "Timeago|in %s minutes" +msgstr "en %s minutos" + +msgid "Timeago|in %s months" +msgstr "en %s meses" + +msgid "Timeago|in %s seconds" +msgstr "en %s segundos" + +msgid "Timeago|in %s weeks" +msgstr "en %s semanas" + +msgid "Timeago|in %s years" +msgstr "en %s años" + +msgid "Timeago|in 1 day" +msgstr "en 1 día" + +msgid "Timeago|in 1 hour" +msgstr "en 1 hora" + +msgid "Timeago|in 1 minute" +msgstr "en 1 minuto" + +msgid "Timeago|in 1 month" +msgstr "en 1 mes" + +msgid "Timeago|in 1 week" +msgstr "en 1 semana" + +msgid "Timeago|in 1 year" +msgstr "en 1 año" + +msgid "Timeago|less than a minute ago" +msgstr "hace menos de 1 minuto" + msgid "Time|hr" msgid_plural "Time|hrs" msgstr[0] "hr" @@ -193,16 +733,91 @@ msgstr "Tiempo Total" msgid "Total test time for all commits/merges" msgstr "Tiempo total de pruebas para todos los cambios o integraciones" +msgid "Unstar" +msgstr "No Destacar" + +msgid "Upload New File" +msgstr "Subir nuevo archivo" + +msgid "Upload file" +msgstr "Subir archivo" + +msgid "Use your global notification setting" +msgstr "Utiliza tu configuración de notificación global" + +msgid "VisibilityLevel|Internal" +msgstr "Interno" + +msgid "VisibilityLevel|Private" +msgstr "Privado" + +msgid "VisibilityLevel|Public" +msgstr "Público" + msgid "Want to see the data? Please ask an administrator for access." msgstr "¿Quieres ver los datos? Por favor pide acceso al administrador." msgid "We don't have enough data to show this stage." msgstr "No hay suficientes datos para mostrar en esta etapa." +msgid "Withdraw Access Request" +msgstr "Retirar Solicitud de Acceso" + +msgid "" +"You are going to remove %{project_name_with_namespace}.\n" +"Removed project CANNOT be restored!\n" +"Are you ABSOLUTELY sure?" +msgstr "" +"Va a eliminar %{project_name_with_namespace}.\n" +"¡El proyecto eliminado NO puede ser restaurado!\n" +"¿Estás TOTALMENTE seguro?" + +msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?" +msgstr "Vas a eliminar el enlace de la bifurcación con el proyecto original %{forked_from_project}. ¿Estás TOTALMENTE seguro?" + +msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" +msgstr "Vas a transferir %{project_name_with_namespace} a otro propietario. ¿Estás TOTALMENTE seguro?" + +msgid "You can only add files when you are on a branch" +msgstr "Sólo puede agregar archivos cuando estas en una rama" + +msgid "You must sign in to star a project" +msgstr "Debes iniciar sesión para destacar un proyecto" + msgid "You need permission." msgstr "Necesitas permisos." +msgid "You will not get any notifications via email" +msgstr "No recibirás ninguna notificación por correo electrónico" + +msgid "You will only receive notifications for the events you choose" +msgstr "Solo recibirás notificaciones de los eventos que elijas" + +msgid "You will only receive notifications for threads you have participated in" +msgstr "Solo recibirás notificaciones de los temas en los que has participado" + +msgid "You will receive notifications for any activity" +msgstr "Recibirás notificaciones para cualquier actividad" + +msgid "You will receive notifications only for comments in which you were @mentioned" +msgstr "Recibirás notificaciones sólo para los comentarios en los que se te mencionó" + +msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account" +msgstr "No podrás actualizar o enviar código al proyecto a través de %{protocol} hasta que %{set_password_link} en tu cuenta" + +msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile" +msgstr "No podrás actualizar o enviar código al proyecto a través de SSH hasta que %{add_ssh_key_link} en su perfil" + +msgid "Your name" +msgstr "Tu nombre" + +msgid "committed" +msgstr "cambió" + msgid "day" msgid_plural "days" msgstr[0] "día" msgstr[1] "días" + +msgid "notification emails" +msgstr "correos electrónicos de notificación" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3967d40ea9e..483412baa05 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-05-04 19:24-0500\n" -"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"POT-Creation-Date: 2017-06-07 17:36+0200\n" +"PO-Revision-Date: 2017-06-07 17:36+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -18,14 +18,160 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" +msgid "About auto deploy" +msgstr "" + +msgid "Activity" +msgstr "" + +msgid "Add Changelog" +msgstr "" + +msgid "Add Contribution guide" +msgstr "" + +msgid "Add License" +msgstr "" + +msgid "Add an SSH key to your profile to pull or push via SSH." +msgstr "" + +msgid "Add new directory" +msgstr "" + +msgid "Archived project! Repository is read-only" +msgstr "" + +msgid "Branch" +msgid_plural "Branches" +msgstr[0] "" +msgstr[1] "" + +msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}" +msgstr "" + +msgid "Branches" +msgstr "" + msgid "ByAuthor|by" msgstr "" +msgid "CI configuration" +msgstr "" + +msgid "Changelog" +msgstr "" + +msgid "Charts" +msgstr "" + +msgid "CiStatusLabel|canceled" +msgstr "" + +msgid "CiStatusLabel|created" +msgstr "" + +msgid "CiStatusLabel|failed" +msgstr "" + +msgid "CiStatusLabel|manual action" +msgstr "" + +msgid "CiStatusLabel|passed" +msgstr "" + +msgid "CiStatusLabel|passed with warnings" +msgstr "" + +msgid "CiStatusLabel|pending" +msgstr "" + +msgid "CiStatusLabel|skipped" +msgstr "" + +msgid "CiStatusLabel|waiting for manual action" +msgstr "" + +msgid "CiStatusText|blocked" +msgstr "" + +msgid "CiStatusText|canceled" +msgstr "" + +msgid "CiStatusText|created" +msgstr "" + +msgid "CiStatusText|failed" +msgstr "" + +msgid "CiStatusText|manual" +msgstr "" + +msgid "CiStatusText|passed" +msgstr "" + +msgid "CiStatusText|pending" +msgstr "" + +msgid "CiStatusText|skipped" +msgstr "" + +msgid "CiStatus|running" +msgstr "" + msgid "Commit" msgid_plural "Commits" msgstr[0] "" msgstr[1] "" +msgid "CommitMessage|Add %{file_name}" +msgstr "" + +msgid "Commits" +msgstr "" + +msgid "Commits|History" +msgstr "" + +msgid "Compare" +msgstr "" + +msgid "Contribution guide" +msgstr "" + +msgid "Contributors" +msgstr "" + +msgid "Copy URL to clipboard" +msgstr "" + +msgid "Copy commit SHA to clipboard" +msgstr "" + +msgid "Create New Directory" +msgstr "" + +msgid "Create directory" +msgstr "" + +msgid "Create empty bare repository" +msgstr "" + +msgid "Create merge request" +msgstr "" + +msgid "CreateNewFork|Fork" +msgstr "" + +msgid "Custom notification events" +msgstr "" + +msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}." +msgstr "" + +msgid "Cycle Analytics" +msgstr "" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" @@ -55,26 +201,98 @@ msgid_plural "Deploys" msgstr[0] "" msgstr[1] "" +msgid "Directory name" +msgstr "" + +msgid "Don't show again" +msgstr "" + +msgid "Download tar" +msgstr "" + +msgid "Download tar.bz2" +msgstr "" + +msgid "Download tar.gz" +msgstr "" + +msgid "Download zip" +msgstr "" + +msgid "DownloadArtifacts|Download" +msgstr "" + +msgid "DownloadSource|Download" +msgstr "" + +msgid "Files" +msgstr "" + +msgid "Find by path" +msgstr "" + +msgid "Find file" +msgstr "" + msgid "FirstPushedBy|First" msgstr "" msgid "FirstPushedBy|pushed by" msgstr "" +msgid "ForkedFromProjectPath|Forked from" +msgstr "" + +msgid "Forks" +msgstr "" + msgid "From issue creation until deploy to production" msgstr "" msgid "From merge request merge until deploy to production" msgstr "" +msgid "Go to your fork" +msgstr "" + +msgid "GoToYourFork|Fork" +msgstr "" + +msgid "Home" +msgstr "" + +msgid "Housekeeping successfully started" +msgstr "" + +msgid "Import repository" +msgstr "" + msgid "Introducing Cycle Analytics" msgstr "" +msgid "LFSStatus|Disabled" +msgstr "" + +msgid "LFSStatus|Enabled" +msgstr "" + msgid "Last %d day" msgid_plural "Last %d days" msgstr[0] "" msgstr[1] "" +msgid "Last Update" +msgstr "" + +msgid "Last commit" +msgstr "" + +msgid "Leave group" +msgstr "" + +msgid "Leave project" +msgstr "" + msgid "Limited to showing %d event at most" msgid_plural "Limited to showing %d events at most" msgstr[0] "" @@ -83,29 +301,167 @@ msgstr[1] "" msgid "Median" msgstr "" +msgid "MissingSSHKeyWarningLink|add an SSH key" +msgstr "" + msgid "New Issue" msgid_plural "New Issues" msgstr[0] "" msgstr[1] "" +msgid "New branch" +msgstr "" + +msgid "New directory" +msgstr "" + +msgid "New file" +msgstr "" + +msgid "New issue" +msgstr "" + +msgid "New merge request" +msgstr "" + +msgid "New snippet" +msgstr "" + +msgid "New tag" +msgstr "" + +msgid "No repository" +msgstr "" + msgid "Not available" msgstr "" msgid "Not enough data" msgstr "" +msgid "Notification events" +msgstr "" + +msgid "NotificationEvent|Close issue" +msgstr "" + +msgid "NotificationEvent|Close merge request" +msgstr "" + +msgid "NotificationEvent|Failed pipeline" +msgstr "" + +msgid "NotificationEvent|Merge merge request" +msgstr "" + +msgid "NotificationEvent|New issue" +msgstr "" + +msgid "NotificationEvent|New merge request" +msgstr "" + +msgid "NotificationEvent|New note" +msgstr "" + +msgid "NotificationEvent|Reassign issue" +msgstr "" + +msgid "NotificationEvent|Reassign merge request" +msgstr "" + +msgid "NotificationEvent|Reopen issue" +msgstr "" + +msgid "NotificationEvent|Successful pipeline" +msgstr "" + +msgid "NotificationLevel|Custom" +msgstr "" + +msgid "NotificationLevel|Disabled" +msgstr "" + +msgid "NotificationLevel|Global" +msgstr "" + +msgid "NotificationLevel|On mention" +msgstr "" + +msgid "NotificationLevel|Participate" +msgstr "" + +msgid "NotificationLevel|Watch" +msgstr "" + msgid "OpenedNDaysAgo|Opened" msgstr "" msgid "Pipeline Health" msgstr "" +msgid "Project '%{project_name}' queued for deletion." +msgstr "" + +msgid "Project '%{project_name}' was successfully created." +msgstr "" + +msgid "Project '%{project_name}' was successfully updated." +msgstr "" + +msgid "Project '%{project_name}' will be deleted." +msgstr "" + +msgid "Project access must be granted explicitly to each user." +msgstr "" + +msgid "Project export could not be deleted." +msgstr "" + +msgid "Project export has been deleted." +msgstr "" + +msgid "Project export link has expired. Please generate a new export from your project settings." +msgstr "" + +msgid "Project export started. A download link will be sent by email." +msgstr "" + +msgid "Project home" +msgstr "" + +msgid "ProjectFeature|Disabled" +msgstr "" + +msgid "ProjectFeature|Everyone with access" +msgstr "" + +msgid "ProjectFeature|Only team members" +msgstr "" + +msgid "ProjectFileTree|Name" +msgstr "" + +msgid "ProjectLastActivity|Never" +msgstr "" + msgid "ProjectLifecycle|Stage" msgstr "" +msgid "ProjectNetworkGraph|Graph" +msgstr "" + msgid "Read more" msgstr "" +msgid "Readme" +msgstr "" + +msgid "RefSwitcher|Branches" +msgstr "" + +msgid "RefSwitcher|Tags" +msgstr "" + msgid "Related Commits" msgstr "" @@ -124,17 +480,67 @@ msgstr "" msgid "Related Merged Requests" msgstr "" +msgid "Remind later" +msgstr "" + +msgid "Remove project" +msgstr "" + +msgid "Request Access" +msgstr "" + +msgid "Search branches and tags" +msgstr "" + +msgid "Select Archive Format" +msgstr "" + +msgid "Set a password on your account to pull or push via %{protocol}" +msgstr "" + +msgid "Set up CI" +msgstr "" + +msgid "Set up Koding" +msgstr "" + +msgid "Set up auto deploy" +msgstr "" + +msgid "SetPasswordToCloneLink|set a password" +msgstr "" + msgid "Showing %d event" msgid_plural "Showing %d events" msgstr[0] "" msgstr[1] "" +msgid "Source code" +msgstr "" + +msgid "StarProject|Star" +msgstr "" + +msgid "Switch branch/tag" +msgstr "" + +msgid "Tag" +msgid_plural "Tags" +msgstr[0] "" +msgstr[1] "" + +msgid "Tags" +msgstr "" + msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" msgid "The collection of events added to the data gathered for that stage." msgstr "" +msgid "The fork relationship has been removed." +msgstr "" + msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" @@ -147,6 +553,15 @@ msgstr "" msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." msgstr "" +msgid "The project can be accessed by any logged in user." +msgstr "" + +msgid "The project can be accessed without any authentication." +msgstr "" + +msgid "The repository for this project does not exist." +msgstr "" + msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "" @@ -162,6 +577,9 @@ msgstr "" msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "" +msgid "This means you can not push code until you create an empty repository or import existing one." +msgstr "" + msgid "Time before an issue gets scheduled" msgstr "" @@ -174,6 +592,129 @@ msgstr "" msgid "Time until first merge request" msgstr "" +msgid "Timeago|%s days ago" +msgstr "" + +msgid "Timeago|%s days remaining" +msgstr "" + +msgid "Timeago|%s hours remaining" +msgstr "" + +msgid "Timeago|%s minutes ago" +msgstr "" + +msgid "Timeago|%s minutes remaining" +msgstr "" + +msgid "Timeago|%s months ago" +msgstr "" + +msgid "Timeago|%s months remaining" +msgstr "" + +msgid "Timeago|%s seconds remaining" +msgstr "" + +msgid "Timeago|%s weeks ago" +msgstr "" + +msgid "Timeago|%s weeks remaining" +msgstr "" + +msgid "Timeago|%s years ago" +msgstr "" + +msgid "Timeago|%s years remaining" +msgstr "" + +msgid "Timeago|1 day remaining" +msgstr "" + +msgid "Timeago|1 hour remaining" +msgstr "" + +msgid "Timeago|1 minute remaining" +msgstr "" + +msgid "Timeago|1 month remaining" +msgstr "" + +msgid "Timeago|1 week remaining" +msgstr "" + +msgid "Timeago|1 year remaining" +msgstr "" + +msgid "Timeago|Past due" +msgstr "" + +msgid "Timeago|a day ago" +msgstr "" + +msgid "Timeago|a month ago" +msgstr "" + +msgid "Timeago|a week ago" +msgstr "" + +msgid "Timeago|a while" +msgstr "" + +msgid "Timeago|a year ago" +msgstr "" + +msgid "Timeago|about %s hours ago" +msgstr "" + +msgid "Timeago|about a minute ago" +msgstr "" + +msgid "Timeago|about an hour ago" +msgstr "" + +msgid "Timeago|in %s days" +msgstr "" + +msgid "Timeago|in %s hours" +msgstr "" + +msgid "Timeago|in %s minutes" +msgstr "" + +msgid "Timeago|in %s months" +msgstr "" + +msgid "Timeago|in %s seconds" +msgstr "" + +msgid "Timeago|in %s weeks" +msgstr "" + +msgid "Timeago|in %s years" +msgstr "" + +msgid "Timeago|in 1 day" +msgstr "" + +msgid "Timeago|in 1 hour" +msgstr "" + +msgid "Timeago|in 1 minute" +msgstr "" + +msgid "Timeago|in 1 month" +msgstr "" + +msgid "Timeago|in 1 week" +msgstr "" + +msgid "Timeago|in 1 year" +msgstr "" + +msgid "Timeago|less than a minute ago" +msgstr "" + msgid "Time|hr" msgid_plural "Time|hrs" msgstr[0] "" @@ -193,16 +734,88 @@ msgstr "" msgid "Total test time for all commits/merges" msgstr "" +msgid "Unstar" +msgstr "" + +msgid "Upload New File" +msgstr "" + +msgid "Upload file" +msgstr "" + +msgid "Use your global notification setting" +msgstr "" + +msgid "VisibilityLevel|Internal" +msgstr "" + +msgid "VisibilityLevel|Private" +msgstr "" + +msgid "VisibilityLevel|Public" +msgstr "" + msgid "Want to see the data? Please ask an administrator for access." msgstr "" msgid "We don't have enough data to show this stage." msgstr "" +msgid "Withdraw Access Request" +msgstr "" + +msgid "" +"You are going to remove %{project_name_with_namespace}.\n" +"Removed project CANNOT be restored!\n" +"Are you ABSOLUTELY sure?" +msgstr "" + +msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?" +msgstr "" + +msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" +msgstr "" + +msgid "You can only add files when you are on a branch" +msgstr "" + +msgid "You must sign in to star a project" +msgstr "" + msgid "You need permission." msgstr "" +msgid "You will not get any notifications via email" +msgstr "" + +msgid "You will only receive notifications for the events you choose" +msgstr "" + +msgid "You will only receive notifications for threads you have participated in" +msgstr "" + +msgid "You will receive notifications for any activity" +msgstr "" + +msgid "You will receive notifications only for comments in which you were @mentioned" +msgstr "" + +msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account" +msgstr "" + +msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile" +msgstr "" + +msgid "Your name" +msgstr "" + +msgid "committed" +msgstr "" + msgid "day" msgid_plural "days" msgstr[0] "" msgstr[1] "" + +msgid "notification emails" +msgstr "" diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po new file mode 100644 index 00000000000..c2d69b122e2 --- /dev/null +++ b/locale/zh_CN/gitlab.po @@ -0,0 +1,225 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-04 19:24-0500\n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"Last-Translator: HuangTao <htve@outlook.com>, 2017\n" +"Language-Team: Chinese (China) (https://www.transifex.com/gitlab-zh/teams/75177/zh_CN/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: zh_CN\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +msgid "ByAuthor|by" +msgstr "作者:" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "提交" + +msgid "" +"Cycle Analytics gives an overview of how much time it takes to go from idea " +"to production in your project." +msgstr "周期分析概述了项目从想法到产品实现的各阶段所需的时间。" + +msgid "CycleAnalyticsStage|Code" +msgstr "编码" + +msgid "CycleAnalyticsStage|Issue" +msgstr "议题" + +msgid "CycleAnalyticsStage|Plan" +msgstr "计划" + +msgid "CycleAnalyticsStage|Production" +msgstr "生产" + +msgid "CycleAnalyticsStage|Review" +msgstr "评审" + +msgid "CycleAnalyticsStage|Staging" +msgstr "预发布" + +msgid "CycleAnalyticsStage|Test" +msgstr "测试" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "部署" + +msgid "FirstPushedBy|First" +msgstr "首次推送" + +msgid "FirstPushedBy|pushed by" +msgstr "推送者:" + +msgid "From issue creation until deploy to production" +msgstr "从创建议题到部署至生产环境" + +msgid "From merge request merge until deploy to production" +msgstr "从合并请求被合并后到部署至生产环境" + +msgid "Introducing Cycle Analytics" +msgstr "周期分析简介" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "最后 %d 天" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "最多显示 %d 个事件" + +msgid "Median" +msgstr "中位数" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "新议题" + +msgid "Not available" +msgstr "数据不足" + +msgid "Not enough data" +msgstr "数据不足" + +msgid "OpenedNDaysAgo|Opened" +msgstr "开始于" + +msgid "Pipeline Health" +msgstr "流水线健康指标" + +msgid "ProjectLifecycle|Stage" +msgstr "项目生命周期" + +msgid "Read more" +msgstr "了解更多" + +msgid "Related Commits" +msgstr "相关的提交" + +msgid "Related Deployed Jobs" +msgstr "相关的部署作业" + +msgid "Related Issues" +msgstr "相关的议题" + +msgid "Related Jobs" +msgstr "相关的作业" + +msgid "Related Merge Requests" +msgstr "相关的合并请求" + +msgid "Related Merged Requests" +msgstr "相关已合并的合并请求" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "显示 %d 个事件" + +msgid "" +"The coding stage shows the time from the first commit to creating the merge " +"request. The data will automatically be added here once you create your " +"first merge request." +msgstr "编码阶段概述了从第一次提交到创建合并请求的时间。创建第一个合并请求后,数据将自动添加到此处。" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "与该阶段相关的事件。" + +msgid "" +"The issue stage shows the time it takes from creating an issue to assigning " +"the issue to a milestone, or add the issue to a list on your Issue Board. " +"Begin creating issues to see data for this stage." +msgstr "议题阶段概述了从创建议题到将议题设置里程碑或将议题添加到议题看板的时间。开始创建议题以查看此阶段的数据。" + +msgid "The phase of the development lifecycle." +msgstr "项目生命周期中的各个阶段。" + +msgid "" +"The planning stage shows the time from the previous step to pushing your " +"first commit. This time will be added automatically once you push your first" +" commit." +msgstr "计划阶段概述了从议题添加到日程后到推送首次提交的时间。当首次推送提交后,数据将自动添加到此处。" + +msgid "" +"The production stage shows the total time it takes between creating an issue" +" and deploying the code to production. The data will be automatically added " +"once you have completed the full idea to production cycle." +msgstr "生产阶段概述了从创建一个议题到将代码部署到生产环境的总时间。当完成想法到部署生产的循环,数据将自动添加到此处。" + +msgid "" +"The review stage shows the time from creating the merge request to merging " +"it. The data will automatically be added after you merge your first merge " +"request." +msgstr "评审阶段概述了从创建合并请求到被合并的时间。当创建第一个合并请求后,数据将自动添加到此处。" + +msgid "" +"The staging stage shows the time between merging the MR and deploying code " +"to the production environment. The data will be automatically added once you" +" deploy to production for the first time." +msgstr "预发布阶段概述了从合并请求被合并到部署至生产环境的总时间。首次部署到生产环境后,数据将自动添加到此处。" + +msgid "" +"The testing stage shows the time GitLab CI takes to run every pipeline for " +"the related merge request. The data will automatically be added after your " +"first pipeline finishes running." +msgstr "测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "该阶段每条数据所花的时间" + +msgid "" +"The value lying at the midpoint of a series of observed values. E.g., " +"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 " +"= 6." +msgstr "中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (5 + 7)/ 2 = 6。" + +msgid "Time before an issue gets scheduled" +msgstr "议题被列入日程表的时间" + +msgid "Time before an issue starts implementation" +msgstr "开始进行编码前的时间" + +msgid "Time between merge request creation and merge/close" +msgstr "从创建合并请求到被合并或关闭的时间" + +msgid "Time until first merge request" +msgstr "创建第一个合并请求之前的时间" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "小时" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "分钟" + +msgid "Time|s" +msgstr "秒" + +msgid "Total Time" +msgstr "总时间" + +msgid "Total test time for all commits/merges" +msgstr "所有提交和合并的总测试时间" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "权限不足。如需查看相关数据,请向管理员申请权限。" + +msgid "We don't have enough data to show this stage." +msgstr "该阶段的数据不足,无法显示。" + +msgid "You need permission." +msgstr "您需要相关的权限。" + +msgid "day" +msgid_plural "days" +msgstr[0] "天" diff --git a/locale/zh_CN/gitlab.po.time_stamp b/locale/zh_CN/gitlab.po.time_stamp new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/locale/zh_CN/gitlab.po.time_stamp diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po new file mode 100644 index 00000000000..6d56b2897fa --- /dev/null +++ b/locale/zh_HK/gitlab.po @@ -0,0 +1,225 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-04 19:24-0500\n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"Last-Translator: HuangTao <htve@outlook.com>, 2017\n" +"Language-Team: Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/75177/zh_HK/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: zh_HK\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +msgid "ByAuthor|by" +msgstr "作者:" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "提交" + +msgid "" +"Cycle Analytics gives an overview of how much time it takes to go from idea " +"to production in your project." +msgstr "週期分析概述了項目從想法到產品實現的各階段所需的時間。" + +msgid "CycleAnalyticsStage|Code" +msgstr "編碼" + +msgid "CycleAnalyticsStage|Issue" +msgstr "議題" + +msgid "CycleAnalyticsStage|Plan" +msgstr "計劃" + +msgid "CycleAnalyticsStage|Production" +msgstr "生產" + +msgid "CycleAnalyticsStage|Review" +msgstr "評審" + +msgid "CycleAnalyticsStage|Staging" +msgstr "預發布" + +msgid "CycleAnalyticsStage|Test" +msgstr "測試" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "部署" + +msgid "FirstPushedBy|First" +msgstr "首次推送" + +msgid "FirstPushedBy|pushed by" +msgstr "推送者:" + +msgid "From issue creation until deploy to production" +msgstr "從創建議題到部署到生產環境" + +msgid "From merge request merge until deploy to production" +msgstr "從合併請求的合併到部署至生產環境" + +msgid "Introducing Cycle Analytics" +msgstr "週期分析簡介" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "最後 %d 天" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "最多顯示 %d 個事件" + +msgid "Median" +msgstr "中位數" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "新議題" + +msgid "Not available" +msgstr "不可用" + +msgid "Not enough data" +msgstr "數據不足" + +msgid "OpenedNDaysAgo|Opened" +msgstr "開始於" + +msgid "Pipeline Health" +msgstr "流水線健康指標" + +msgid "ProjectLifecycle|Stage" +msgstr "項目生命週期" + +msgid "Read more" +msgstr "了解更多" + +msgid "Related Commits" +msgstr "相關的提交" + +msgid "Related Deployed Jobs" +msgstr "相關的部署作業" + +msgid "Related Issues" +msgstr "相關的議題" + +msgid "Related Jobs" +msgstr "相關的作業" + +msgid "Related Merge Requests" +msgstr "相關的合併請求" + +msgid "Related Merged Requests" +msgstr "相關已合併的合並請求" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "顯示 %d 個事件" + +msgid "" +"The coding stage shows the time from the first commit to creating the merge " +"request. The data will automatically be added here once you create your " +"first merge request." +msgstr "編碼階段概述了從第一次提交到創建合併請求的時間。創建第壹個合並請求後,數據將自動添加到此處。" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "與該階段相關的事件。" + +msgid "" +"The issue stage shows the time it takes from creating an issue to assigning " +"the issue to a milestone, or add the issue to a list on your Issue Board. " +"Begin creating issues to see data for this stage." +msgstr "議題階段概述了從創建議題到將議題設置裏程碑或將議題添加到議題看板的時間。創建一個議題後,數據將自動添加到此處。" + +msgid "The phase of the development lifecycle." +msgstr "項目生命週期中的各個階段。" + +msgid "" +"The planning stage shows the time from the previous step to pushing your " +"first commit. This time will be added automatically once you push your first" +" commit." +msgstr "計劃階段概述了從議題添加到日程後到推送首次提交的時間。當首次推送提交後,數據將自動添加到此處。" + +msgid "" +"The production stage shows the total time it takes between creating an issue" +" and deploying the code to production. The data will be automatically added " +"once you have completed the full idea to production cycle." +msgstr "生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完成完整的想法到部署生產,數據將自動添加到此處。" + +msgid "" +"The review stage shows the time from creating the merge request to merging " +"it. The data will automatically be added after you merge your first merge " +"request." +msgstr "評審階段概述了從創建合並請求到合併的時間。當創建第壹個合並請求後,數據將自動添加到此處。" + +msgid "" +"The staging stage shows the time between merging the MR and deploying code " +"to the production environment. The data will be automatically added once you" +" deploy to production for the first time." +msgstr "預發布階段概述了合並請求的合併到部署代碼到生產環境的總時間。當首次部署到生產環境後,數據將自動添加到此處。" + +msgid "" +"The testing stage shows the time GitLab CI takes to run every pipeline for " +"the related merge request. The data will automatically be added after your " +"first pipeline finishes running." +msgstr "測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "該階段每條數據所花的時間" + +msgid "" +"The value lying at the midpoint of a series of observed values. E.g., " +"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 " +"= 6." +msgstr "中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。" + +msgid "Time before an issue gets scheduled" +msgstr "議題被列入日程表的時間" + +msgid "Time before an issue starts implementation" +msgstr "開始進行編碼前的時間" + +msgid "Time between merge request creation and merge/close" +msgstr "從創建合併請求到被合並或關閉的時間" + +msgid "Time until first merge request" +msgstr "創建第壹個合併請求之前的時間" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "小時" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "分鐘" + +msgid "Time|s" +msgstr "秒" + +msgid "Total Time" +msgstr "總時間" + +msgid "Total test time for all commits/merges" +msgstr "所有提交和合併的總測試時間" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "權限不足。如需查看相關數據,請向管理員申請權限。" + +msgid "We don't have enough data to show this stage." +msgstr "該階段的數據不足,無法顯示。" + +msgid "You need permission." +msgstr "您需要相關的權限。" + +msgid "day" +msgid_plural "days" +msgstr[0] "天" diff --git a/locale/zh_HK/gitlab.po.time_stamp b/locale/zh_HK/gitlab.po.time_stamp new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/locale/zh_HK/gitlab.po.time_stamp diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po new file mode 100644 index 00000000000..0caf35a915b --- /dev/null +++ b/locale/zh_TW/gitlab.po @@ -0,0 +1,225 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-04 19:24-0500\n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"Last-Translator: HuangTao <htve@outlook.com>, 2017\n" +"Language-Team: Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/75177/zh_TW/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: zh_TW\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +msgid "ByAuthor|by" +msgstr "作者:" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "送交" + +msgid "" +"Cycle Analytics gives an overview of how much time it takes to go from idea " +"to production in your project." +msgstr "週期分析概述了你的專案從想法到產品實現,各階段所需的時間。" + +msgid "CycleAnalyticsStage|Code" +msgstr "程式開發" + +msgid "CycleAnalyticsStage|Issue" +msgstr "議題" + +msgid "CycleAnalyticsStage|Plan" +msgstr "計劃" + +msgid "CycleAnalyticsStage|Production" +msgstr "上線" + +msgid "CycleAnalyticsStage|Review" +msgstr "複閱" + +msgid "CycleAnalyticsStage|Staging" +msgstr "預備" + +msgid "CycleAnalyticsStage|Test" +msgstr "測試" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "部署" + +msgid "FirstPushedBy|First" +msgstr "首次推送" + +msgid "FirstPushedBy|pushed by" +msgstr "推送者:" + +msgid "From issue creation until deploy to production" +msgstr "從議題建立至線上部署" + +msgid "From merge request merge until deploy to production" +msgstr "從請求被合併後至線上部署" + +msgid "Introducing Cycle Analytics" +msgstr "週期分析簡介" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "最後 %d 天" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "最多顯示 %d 個事件" + +msgid "Median" +msgstr "中位數" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "新議題" + +msgid "Not available" +msgstr "無法使用" + +msgid "Not enough data" +msgstr "資料不足" + +msgid "OpenedNDaysAgo|Opened" +msgstr "開始於" + +msgid "Pipeline Health" +msgstr "流水線健康指標" + +msgid "ProjectLifecycle|Stage" +msgstr "專案生命週期" + +msgid "Read more" +msgstr "了解更多" + +msgid "Related Commits" +msgstr "相關的送交" + +msgid "Related Deployed Jobs" +msgstr "相關的部署作業" + +msgid "Related Issues" +msgstr "相關的議題" + +msgid "Related Jobs" +msgstr "相關的作業" + +msgid "Related Merge Requests" +msgstr "相關的合併請求" + +msgid "Related Merged Requests" +msgstr "相關已合併的請求" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "顯示 %d 個事件" + +msgid "" +"The coding stage shows the time from the first commit to creating the merge " +"request. The data will automatically be added here once you create your " +"first merge request." +msgstr "程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "與該階段相關的事件。" + +msgid "" +"The issue stage shows the time it takes from creating an issue to assigning " +"the issue to a milestone, or add the issue to a list on your Issue Board. " +"Begin creating issues to see data for this stage." +msgstr "議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。" + +msgid "The phase of the development lifecycle." +msgstr "專案開發生命週期的各個階段。" + +msgid "" +"The planning stage shows the time from the previous step to pushing your " +"first commit. This time will be added automatically once you push your first" +" commit." +msgstr "計劃階段顯示從議題添加到日程後至推送第一個送交的時間。當第一次推送送交後,資料將自動填入。" + +msgid "" +"The production stage shows the total time it takes between creating an issue" +" and deploying the code to production. The data will be automatically added " +"once you have completed the full idea to production cycle." +msgstr "上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。" + +msgid "" +"The review stage shows the time from creating the merge request to merging " +"it. The data will automatically be added after you merge your first merge " +"request." +msgstr "複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。" + +msgid "" +"The staging stage shows the time between merging the MR and deploying code " +"to the production environment. The data will be automatically added once you" +" deploy to production for the first time." +msgstr "預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。" + +msgid "" +"The testing stage shows the time GitLab CI takes to run every pipeline for " +"the related merge request. The data will automatically be added after your " +"first pipeline finishes running." +msgstr "測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "每筆該階段相關資料所花的時間。" + +msgid "" +"The value lying at the midpoint of a series of observed values. E.g., " +"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 " +"= 6." +msgstr "中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。" + +msgid "Time before an issue gets scheduled" +msgstr "議題被列入日程表的時間" + +msgid "Time before an issue starts implementation" +msgstr "議題等待開始實作的時間" + +msgid "Time between merge request creation and merge/close" +msgstr "合併請求被合併或是關閉的時間" + +msgid "Time until first merge request" +msgstr "第一個合併請求被建立前的時間" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "小時" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "分鐘" + +msgid "Time|s" +msgstr "秒" + +msgid "Total Time" +msgstr "總時間" + +msgid "Total test time for all commits/merges" +msgstr "所有送交和合併的總測試時間" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "權限不足。如需查看相關資料,請向管理員申請權限。" + +msgid "We don't have enough data to show this stage." +msgstr "因該階段的資料不足而無法顯示相關資訊" + +msgid "You need permission." +msgstr "您需要相關的權限。" + +msgid "day" +msgid_plural "days" +msgstr[0] "天" diff --git a/locale/zh_TW/gitlab.po.time_stamp b/locale/zh_TW/gitlab.po.time_stamp new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/locale/zh_TW/gitlab.po.time_stamp diff --git a/package.json b/package.json index 800327d8a08..29165fd4182 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "core-js": "^2.4.1", "css-loader": "^0.28.0", "d3": "^3.5.11", + "deckar01-task_list": "^2.0.0", "document-register-element": "^1.3.0", "dropzone": "^4.2.0", "emoji-unicode-version": "^0.2.1", @@ -36,6 +37,7 @@ "jszip-utils": "^0.0.2", "marked": "^0.3.6", "mousetrap": "^1.4.6", + "name-all-modules-plugin": "^1.0.1", "pdfjs-dist": "^1.8.252", "pikaday": "^1.5.1", "prismjs": "^1.6.0", @@ -57,8 +59,8 @@ "vue-loader": "^11.3.4", "vue-resource": "^0.9.3", "vue-template-compiler": "^2.2.6", - "webpack": "^2.3.3", - "webpack-bundle-analyzer": "^2.3.0" + "webpack": "^2.6.1", + "webpack-bundle-analyzer": "^2.8.2" }, "devDependencies": { "babel-plugin-istanbul": "^4.0.0", diff --git a/qa/Dockerfile b/qa/Dockerfile index 72c82503542..9e2a74ef991 100644 --- a/qa/Dockerfile +++ b/qa/Dockerfile @@ -1,10 +1,25 @@ FROM ruby:2.3 LABEL maintainer "Grzegorz Bizon <grzegorz@gitlab.com>" -RUN sed -i "s/httpredir.debian.org/ftp.us.debian.org/" /etc/apt/sources.list && \ - apt-get update && apt-get install -y --force-yes \ - libqt5webkit5-dev qt5-qmake qt5-default build-essential xvfb git && \ - apt-get clean +## +# Update APT sources and install some dependencies +# +RUN sed -i "s/httpredir.debian.org/ftp.us.debian.org/" /etc/apt/sources.list +RUN apt-get update && apt-get install -y wget git unzip xvfb + +## +# At this point Google Chrome Beta is 59 - first version with headless support +# +RUN wget -q https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb +RUN dpkg -i google-chrome-beta_current_amd64.deb; apt-get -fy install + +## +# Install chromedriver to make it work with Selenium +# +RUN wget -q https://chromedriver.storage.googleapis.com/2.29/chromedriver_linux64.zip +RUN unzip chromedriver_linux64.zip -d /usr/local/bin + +RUN apt-get clean WORKDIR /home/qa diff --git a/qa/Gemfile b/qa/Gemfile index 6bfe25ba437..5d089a45934 100644 --- a/qa/Gemfile +++ b/qa/Gemfile @@ -2,6 +2,6 @@ source 'https://rubygems.org' gem 'capybara', '~> 2.12.1' gem 'capybara-screenshot', '~> 1.0.14' -gem 'capybara-webkit', '~> 1.12.0' gem 'rake', '~> 12.0.0' gem 'rspec', '~> 3.5' +gem 'selenium-webdriver', '~> 2.53' diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 6de2abff198..4dd71aa5010 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -16,7 +16,10 @@ GEM capybara-webkit (1.12.0) capybara (>= 2.3.0, < 2.13.0) json + childprocess (0.7.0) + ffi (~> 1.0, >= 1.0.11) diff-lcs (1.3) + ffi (1.9.18) json (2.0.3) launchy (2.4.3) addressable (~> 2.3) @@ -44,6 +47,12 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.5.0) rspec-support (3.5.0) + rubyzip (1.2.1) + selenium-webdriver (2.53.4) + childprocess (~> 0.5) + rubyzip (~> 1.0) + websocket (~> 1.0) + websocket (1.2.4) xpath (2.0.0) nokogiri (~> 1.3) @@ -56,6 +65,7 @@ DEPENDENCIES capybara-webkit (~> 1.12.0) rake (~> 12.0.0) rspec (~> 3.5) + selenium-webdriver (~> 2.53) BUNDLED WITH 1.14.6 diff --git a/qa/qa/specs/config.rb b/qa/qa/specs/config.rb index d72187fcd34..78a93828d36 100644 --- a/qa/qa/specs/config.rb +++ b/qa/qa/specs/config.rb @@ -1,7 +1,7 @@ require 'rspec/core' require 'capybara/rspec' -require 'capybara-webkit' require 'capybara-screenshot/rspec' +require 'selenium-webdriver' # rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/LineLength @@ -20,7 +20,6 @@ module QA configure_rspec! configure_capybara! - configure_webkit! end def configure_rspec! @@ -43,9 +42,9 @@ module QA config.order = :random Kernel.srand config.seed - config.before(:all) do - page.current_window.resize_to(1200, 1800) - end + # config.before(:all) do + # page.current_window.resize_to(1200, 1800) + # end config.formatter = :documentation config.color = true @@ -53,26 +52,28 @@ module QA end def configure_capybara! + Capybara.register_driver :chrome do |app| + capabilities = Selenium::WebDriver::Remote::Capabilities.chrome( + 'chromeOptions' => { + 'binary' => '/opt/google/chrome-beta/google-chrome-beta', + 'args' => %w[headless no-sandbox disable-gpu] + } + ) + + Capybara::Selenium::Driver + .new(app, browser: :chrome, desired_capabilities: capabilities) + end + Capybara.configure do |config| config.app_host = @address - config.default_driver = :webkit - config.javascript_driver = :webkit + config.default_driver = :chrome + config.javascript_driver = :chrome config.default_max_wait_time = 4 # https://github.com/mattheworiordan/capybara-screenshot/issues/164 config.save_path = 'tmp' end end - - def configure_webkit! - Capybara::Webkit.configure do |config| - config.allow_url(@address) - config.block_unknown_urls - end - rescue RuntimeError # rubocop:disable Lint/HandleExceptions - # TODO, Webkit is already configured, this make this - # configuration step idempotent, should be improved. - end end end end diff --git a/qa/spec/spec_helper.rb b/qa/spec/spec_helper.rb index c07a3234673..64d06ef6558 100644 --- a/qa/spec/spec_helper.rb +++ b/qa/spec/spec_helper.rb @@ -12,7 +12,6 @@ RSpec.configure do |config| config.shared_context_metadata_behavior = :apply_to_host_groups config.disable_monkey_patching! config.expose_dsl_globally = true - config.warnings = true config.profile_examples = 10 config.order = :random Kernel.srand config.seed diff --git a/rubocop/cop/activerecord_serialize.rb b/rubocop/cop/activerecord_serialize.rb new file mode 100644 index 00000000000..9bdcc3b4c34 --- /dev/null +++ b/rubocop/cop/activerecord_serialize.rb @@ -0,0 +1,18 @@ +require_relative '../model_helpers' + +module RuboCop + module Cop + # Cop that prevents the use of `serialize` in ActiveRecord models. + class ActiverecordSerialize < RuboCop::Cop::Cop + include ModelHelpers + + MSG = 'Do not store serialized data in the database, use separate columns and/or tables instead'.freeze + + def on_send(node) + return unless in_model?(node) + + add_offense(node, :selector) if node.children[1] == :serialize + end + end + end +end diff --git a/rubocop/cop/migration/update_column_in_batches.rb b/rubocop/cop/migration/update_column_in_batches.rb new file mode 100644 index 00000000000..3f886cbfea3 --- /dev/null +++ b/rubocop/cop/migration/update_column_in_batches.rb @@ -0,0 +1,43 @@ +require_relative '../../migration_helpers' + +module RuboCop + module Cop + module Migration + # Cop that checks if a spec file exists for any migration using + # `update_column_in_batches`. + class UpdateColumnInBatches < RuboCop::Cop::Cop + include MigrationHelpers + + MSG = 'Migration running `update_column_in_batches` must have a spec file at' \ + ' `%s`.'.freeze + + def on_send(node) + return unless in_migration?(node) + return unless node.children[1] == :update_column_in_batches + + spec_path = spec_filename(node) + + unless File.exist?(File.expand_path(spec_path, rails_root)) + add_offense(node, :expression, format(MSG, spec_path)) + end + end + + private + + def spec_filename(node) + source_name = node.location.expression.source_buffer.name + path = Pathname.new(source_name).relative_path_from(rails_root) + dirname = File.dirname(path) + .sub(%r{\Adb/(migrate|post_migrate)}, 'spec/migrations') + filename = File.basename(source_name, '.rb').sub(%r{\A\d+_}, '') + + File.join(dirname, "#{filename}_spec.rb") + end + + def rails_root + Pathname.new(File.expand_path('../../..', __dir__)) + end + end + end + end +end diff --git a/rubocop/cop/polymorphic_associations.rb b/rubocop/cop/polymorphic_associations.rb new file mode 100644 index 00000000000..7d554704550 --- /dev/null +++ b/rubocop/cop/polymorphic_associations.rb @@ -0,0 +1,23 @@ +require_relative '../model_helpers' + +module RuboCop + module Cop + # Cop that prevents the use of polymorphic associations + class PolymorphicAssociations < RuboCop::Cop::Cop + include ModelHelpers + + MSG = 'Do not use polymorphic associations, use separate tables instead'.freeze + + def on_send(node) + return unless in_model?(node) + return unless node.children[1] == :belongs_to + + node.children.last.each_node(:pair) do |pair| + key_name = pair.children[0].children[0] + + add_offense(pair, :expression) if key_name == :polymorphic + end + end + end + end +end diff --git a/rubocop/cop/redirect_with_status.rb b/rubocop/cop/redirect_with_status.rb new file mode 100644 index 00000000000..36810642c88 --- /dev/null +++ b/rubocop/cop/redirect_with_status.rb @@ -0,0 +1,44 @@ +module RuboCop + module Cop + # This cop prevents usage of 'redirect_to' in actions 'destroy' without specifying 'status'. + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/31840 + class RedirectWithStatus < RuboCop::Cop::Cop + MSG = 'Do not use "redirect_to" without "status" in "destroy" action'.freeze + + def on_def(node) + return unless in_controller?(node) + return unless destroy?(node) || destroy_all?(node) + + node.each_descendant(:send) do |def_node| + next unless redirect_to?(def_node) + + methods = [] + + def_node.children.last.each_node(:pair) do |pair| + methods << pair.children.first.children.first + end + + add_offense(def_node, :selector) unless methods.include?(:status) + end + end + + private + + def in_controller?(node) + node.location.expression.source_buffer.name.end_with?('_controller.rb') + end + + def destroy?(node) + node.children.first == :destroy + end + + def destroy_all?(node) + node.children.first == :destroy_all + end + + def redirect_to?(node) + node.children[1] == :redirect_to + end + end + end +end diff --git a/rubocop/migration_helpers.rb b/rubocop/migration_helpers.rb index 3160a784a04..c3473771178 100644 --- a/rubocop/migration_helpers.rb +++ b/rubocop/migration_helpers.rb @@ -3,8 +3,9 @@ module RuboCop module MigrationHelpers # Returns true if the given node originated from the db/migrate directory. def in_migration?(node) - File.dirname(node.location.expression.source_buffer.name). - end_with?('db/migrate') + dirname = File.dirname(node.location.expression.source_buffer.name) + + dirname.end_with?('db/migrate', 'db/post_migrate') end end end diff --git a/rubocop/model_helpers.rb b/rubocop/model_helpers.rb new file mode 100644 index 00000000000..309723dc34c --- /dev/null +++ b/rubocop/model_helpers.rb @@ -0,0 +1,11 @@ +module RuboCop + module ModelHelpers + # Returns true if the given node originated from the models directory. + def in_model?(node) + path = node.location.expression.source_buffer.name + models_path = File.join(Dir.pwd, 'app', 'models') + + path.start_with?(models_path) + end + end +end diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb index 4ff204f939e..dae30969abf 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -1,5 +1,8 @@ require_relative 'cop/custom_error_class' require_relative 'cop/gem_fetcher' +require_relative 'cop/activerecord_serialize' +require_relative 'cop/redirect_with_status' +require_relative 'cop/polymorphic_associations' require_relative 'cop/migration/add_column' require_relative 'cop/migration/add_column_with_default_to_large_table' require_relative 'cop/migration/add_concurrent_foreign_key' @@ -8,3 +11,4 @@ require_relative 'cop/migration/add_index' require_relative 'cop/migration/remove_concurrent_index' require_relative 'cop/migration/remove_index' require_relative 'cop/migration/reversible_add_column_with_default' +require_relative 'cop/migration/update_column_in_batches' diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index c727a0e2d88..03de59f27ad 100644 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -4,9 +4,22 @@ export SETUP_DB=${SETUP_DB:-true} export USE_BUNDLE_INSTALL=${USE_BUNDLE_INSTALL:-true} export BUNDLE_INSTALL_FLAGS="--without production --jobs $(nproc) --path vendor --retry 3 --quiet" +if [ "$USE_BUNDLE_INSTALL" != "false" ]; then + bundle install --clean $BUNDLE_INSTALL_FLAGS && bundle check +fi + +# Only install knapsack after bundle install! Otherwise oddly some native +# gems could not be found under some circumstance. No idea why, hours wasted. +retry gem install knapsack fog-aws mime-types + +cp config/resque.yml.example config/resque.yml +sed -i 's/localhost/redis/g' config/resque.yml + +cp config/gitlab.yml.example config/gitlab.yml + # Determine the database by looking at the job name. -# For example, we'll get pg if the job is `rspec pg 19 20` -export GITLAB_DATABASE=$(echo $CI_JOB_NAME | cut -f2 -d' ') +# For example, we'll get pg if the job is `rspec-pg 19 20` +export GITLAB_DATABASE=$(echo $CI_JOB_NAME | cut -f1 -d' ' | cut -f2 -d-) # This would make the default database postgresql, and we could also use # pg to mean postgresql. @@ -24,19 +37,6 @@ else # Assume it's mysql sed -i 's/# host:.*/host: mysql/g' config/database.yml fi -cp config/resque.yml.example config/resque.yml -sed -i 's/localhost/redis/g' config/resque.yml - -cp config/gitlab.yml.example config/gitlab.yml - -if [ "$USE_BUNDLE_INSTALL" != "false" ]; then - bundle install --clean $BUNDLE_INSTALL_FLAGS && bundle check -fi - -# Only install knapsack after bundle install! Otherwise oddly some native -# gems could not be found under some circumstance. No idea why, hours wasted. -retry gem install knapsack fog-aws mime-types - if [ "$SETUP_DB" != "false" ]; then bundle exec rake db:drop db:create db:schema:load db:migrate diff --git a/scripts/trigger-build b/scripts/trigger-build new file mode 100755 index 00000000000..e4603533872 --- /dev/null +++ b/scripts/trigger-build @@ -0,0 +1,22 @@ +#!/usr/bin/env ruby + +require 'net/http' +require 'json' + +uri = URI('https://gitlab.com/api/v4/projects/20699/trigger/pipeline') +params = { + "ref" => ENV["OMNIBUS_BRANCH"] || "master", + "token" => ENV["BUILD_TRIGGER_TOKEN"], + "variables[GITLAB_VERSION]" => ENV["CI_COMMIT_SHA"], + "variables[ALTERNATIVE_SOURCES]" => true, + "variables[ee]" => ENV["EE_PACKAGE"] +} + +Dir.glob("*_VERSION").each do |version_file| + params["variables[#{version_file}]"] = File.read(version_file).strip +end + +res = Net::HTTP.post_form(uri, params) +pipeline_id = JSON.parse(res.body)['id'] + +puts "Triggered pipeline can be found at https://gitlab.com/gitlab-org/omnibus-gitlab/pipelines/#{pipeline_id}" diff --git a/spec/bin/changelog_spec.rb b/spec/bin/changelog_spec.rb index 7f4298db59f..91aff0db7cc 100644 --- a/spec/bin/changelog_spec.rb +++ b/spec/bin/changelog_spec.rb @@ -46,9 +46,7 @@ describe 'bin/changelog' do it 'parses -h' do expect do - $stdout = StringIO.new - - described_class.parse(%w[foo -h bar]) + expect { described_class.parse(%w[foo -h bar]) }.to output.to_stdout end.to raise_error(SystemExit) end diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb index c29b2fe8946..ddf38967dd7 100644 --- a/spec/controllers/admin/groups_controller_spec.rb +++ b/spec/controllers/admin/groups_controller_spec.rb @@ -36,6 +36,15 @@ describe Admin::GroupsController do expect(group.users).to include group_user end + it 'can add unlimited members' do + put :members_update, id: group, + user_ids: 1.upto(1000).to_a.join(','), + access_level: Gitlab::Access::GUEST + + expect(response).to set_flash.to 'Users were successfully added.' + expect(response).to redirect_to(admin_group_path(group)) + end + it 'adds no user to members' do put :members_update, id: group, user_ids: '', diff --git a/spec/controllers/admin/hooks_controller_spec.rb b/spec/controllers/admin/hooks_controller_spec.rb new file mode 100644 index 00000000000..1d1070e90f4 --- /dev/null +++ b/spec/controllers/admin/hooks_controller_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe Admin::HooksController do + let(:admin) { create(:admin) } + + before do + sign_in(admin) + end + + describe 'POST #create' do + it 'sets all parameters' do + hook_params = { + enable_ssl_verification: true, + push_events: true, + tag_push_events: true, + repository_update_events: true, + token: "TEST TOKEN", + url: "http://example.com" + } + + post :create, hook: hook_params + + expect(response).to have_http_status(302) + expect(SystemHook.all.size).to eq(1) + expect(SystemHook.first).to have_attributes(hook_params) + end + end +end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 2ab2ca1b667..7d6c317482f 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -10,15 +10,26 @@ describe Admin::UsersController do describe 'DELETE #user with projects' do let(:project) { create(:empty_project, namespace: user.namespace) } + let!(:issue) { create(:issue, author: user) } before do project.team << [user, :developer] end - it 'deletes user' do + it 'deletes user and ghosts their contributions' do delete :destroy, id: user.username, format: :json + + expect(response).to have_http_status(200) + expect(User.exists?(user.id)).to be_falsy + expect(issue.reload.author).to be_ghost + end + + it 'deletes the user and their contributions when hard delete is specified' do + delete :destroy, id: user.username, hard_delete: true, format: :json + expect(response).to have_http_status(200) - expect { User.find(user.id) }.to raise_exception(ActiveRecord::RecordNotFound) + expect(User.exists?(user.id)).to be_falsy + expect(Issue.exists?(issue.id)).to be_falsy end end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index d40aae04fc3..3f99e2ff596 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -99,6 +99,42 @@ describe ApplicationController do end end + describe '#authenticate_user_from_rss_token' do + describe "authenticating a user from an RSS token" do + controller(described_class) do + def index + render text: 'authenticated' + end + end + + context "when the 'rss_token' param is populated with the RSS token" do + context 'when the request format is atom' do + it "logs the user in" do + get :index, rss_token: user.rss_token, format: :atom + expect(response).to have_http_status 200 + expect(response.body).to eq 'authenticated' + end + end + + context 'when the request format is not atom' do + it "doesn't log the user in" do + get :index, rss_token: user.rss_token + expect(response.status).not_to have_http_status 200 + expect(response.body).not_to eq 'authenticated' + end + end + end + + context "when the 'rss_token' param is populated with an invalid RSS token" do + it "doesn't log the user" do + get :index, rss_token: "token" + expect(response.status).not_to eq 200 + expect(response.body).not_to eq 'authenticated' + end + end + end + end + describe '#route_not_found' do it 'renders 404 if authenticated' do 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 0c624def135..4c3a5ec49ef 100644 --- a/spec/controllers/autocomplete_controller_spec.rb +++ b/spec/controllers/autocomplete_controller_spec.rb @@ -97,6 +97,20 @@ describe AutocompleteController do it { expect(body.size).to eq User.count } end + context 'limited users per page' do + let(:per_page) { 2 } + + before do + sign_in(user) + get(:users, per_page: per_page) + end + + let(:body) { JSON.parse(response.body) } + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq per_page } + end + context 'unauthenticated user' do let(:public_project) { create(:project, :public) } let(:body) { JSON.parse(response.body) } diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index c8c1797e4ba..b0b24b1de1b 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -68,7 +68,7 @@ describe GroupsController do before do create_list(:award_emoji, 3, awardable: issue_2) create_list(:award_emoji, 2, awardable: issue_1) - create_list(:award_emoji, 2, :downvote, awardable: issue_2,) + create_list(:award_emoji, 2, :downvote, awardable: issue_2) sign_in(user) end diff --git a/spec/controllers/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb index b8b6e0c3a88..e7c19b47a6a 100644 --- a/spec/controllers/health_controller_spec.rb +++ b/spec/controllers/health_controller_spec.rb @@ -54,43 +54,4 @@ describe HealthController do end end end - - describe '#metrics' do - context 'authorization token provided' do - before do - request.headers['TOKEN'] = token - end - - it 'returns DB ping metrics' do - get :metrics - expect(response.body).to match(/^db_ping_timeout 0$/) - expect(response.body).to match(/^db_ping_success 1$/) - expect(response.body).to match(/^db_ping_latency [0-9\.]+$/) - end - - it 'returns Redis ping metrics' do - get :metrics - expect(response.body).to match(/^redis_ping_timeout 0$/) - expect(response.body).to match(/^redis_ping_success 1$/) - expect(response.body).to match(/^redis_ping_latency [0-9\.]+$/) - end - - it 'returns file system check metrics' do - get :metrics - expect(response.body).to match(/^filesystem_access_latency{shard="default"} [0-9\.]+$/) - expect(response.body).to match(/^filesystem_accessible{shard="default"} 1$/) - expect(response.body).to match(/^filesystem_write_latency{shard="default"} [0-9\.]+$/) - expect(response.body).to match(/^filesystem_writable{shard="default"} 1$/) - expect(response.body).to match(/^filesystem_read_latency{shard="default"} [0-9\.]+$/) - expect(response.body).to match(/^filesystem_readable{shard="default"} 1$/) - end - end - - context 'without authorization token' do - it 'returns proper response' do - get :metrics - expect(response.status).to eq(404) - end - end - end end diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb index 010e3180ea4..0be7bc6a045 100644 --- a/spec/controllers/import/bitbucket_controller_spec.rb +++ b/spec/controllers/import/bitbucket_controller_spec.rb @@ -133,9 +133,13 @@ describe Import::BitbucketController do end context "when a namespace with the Bitbucket user's username already exists" do - let!(:existing_namespace) { create(:namespace, name: other_username, owner: user) } + let!(:existing_namespace) { create(:group, name: other_username) } context "when the namespace is owned by the GitLab user" do + before do + existing_namespace.add_owner(user) + end + it "takes the existing namespace" do expect(Gitlab::BitbucketImport::ProjectCreator). to receive(:new).with(bitbucket_repo, bitbucket_repo.name, existing_namespace, user, access_params). @@ -146,11 +150,6 @@ describe Import::BitbucketController do end context "when the namespace is not owned by the GitLab user" do - before do - existing_namespace.owner = create(:user) - existing_namespace.save - end - it "doesn't create a project" do expect(Gitlab::BitbucketImport::ProjectCreator). not_to receive(:new) @@ -202,10 +201,14 @@ describe Import::BitbucketController do end context 'user has chosen an existing nested namespace and name for the project' do - let(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } - let(:nested_namespace) { create(:namespace, name: 'bar', parent: parent_namespace, owner: user) } + let(:parent_namespace) { create(:group, name: 'foo', owner: user) } + let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) } let(:test_name) { 'test_name' } + before do + nested_namespace.add_owner(user) + end + it 'takes the selected namespace and name' do expect(Gitlab::BitbucketImport::ProjectCreator). to receive(:new).with(bitbucket_repo, test_name, nested_namespace, user, access_params). @@ -248,7 +251,7 @@ describe Import::BitbucketController do context 'user has chosen existent and non-existent nested namespaces and name for the project' do let(:test_name) { 'test_name' } - let!(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } + let!(:parent_namespace) { create(:group, name: 'foo', owner: user) } it 'takes the selected namespace and name' do expect(Gitlab::BitbucketImport::ProjectCreator). diff --git a/spec/controllers/import/gitlab_controller_spec.rb b/spec/controllers/import/gitlab_controller_spec.rb index 3270ea059fa..3afd09063d7 100644 --- a/spec/controllers/import/gitlab_controller_spec.rb +++ b/spec/controllers/import/gitlab_controller_spec.rb @@ -108,9 +108,13 @@ describe Import::GitlabController do end context "when a namespace with the GitLab.com user's username already exists" do - let!(:existing_namespace) { create(:namespace, name: other_username, owner: user) } + let!(:existing_namespace) { create(:group, name: other_username) } context "when the namespace is owned by the GitLab server user" do + before do + existing_namespace.add_owner(user) + end + it "takes the existing namespace" do expect(Gitlab::GitlabImport::ProjectCreator). to receive(:new).with(gitlab_repo, existing_namespace, user, access_params). @@ -121,11 +125,6 @@ describe Import::GitlabController do end context "when the namespace is not owned by the GitLab server user" do - before do - existing_namespace.owner = create(:user) - existing_namespace.save - end - it "doesn't create a project" do expect(Gitlab::GitlabImport::ProjectCreator). not_to receive(:new) @@ -176,8 +175,12 @@ describe Import::GitlabController do end context 'user has chosen an existing nested namespace for the project' do - let(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } - let(:nested_namespace) { create(:namespace, name: 'bar', parent: parent_namespace, owner: user) } + let(:parent_namespace) { create(:group, name: 'foo', owner: user) } + let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) } + + before do + nested_namespace.add_owner(user) + end it 'takes the selected namespace and name' do expect(Gitlab::GitlabImport::ProjectCreator). @@ -221,7 +224,7 @@ describe Import::GitlabController do context 'user has chosen existent and non-existent nested namespaces and name for the project' do let(:test_name) { 'test_name' } - let!(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } + let!(:parent_namespace) { create(:group, name: 'foo', owner: user) } it 'takes the selected namespace and name' do expect(Gitlab::GitlabImport::ProjectCreator). diff --git a/spec/controllers/metrics_controller_spec.rb b/spec/controllers/metrics_controller_spec.rb new file mode 100644 index 00000000000..044c9f179ed --- /dev/null +++ b/spec/controllers/metrics_controller_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe MetricsController do + include StubENV + + let(:token) { current_application_settings.health_check_access_token } + let(:json_response) { JSON.parse(response.body) } + let(:metrics_multiproc_dir) { Dir.mktmpdir } + + before do + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + stub_env('prometheus_multiproc_dir', metrics_multiproc_dir) + allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(true) + end + + describe '#index' do + context 'authorization token provided' do + before do + request.headers['TOKEN'] = token + end + + it 'returns DB ping metrics' do + get :index + + expect(response.body).to match(/^db_ping_timeout 0$/) + expect(response.body).to match(/^db_ping_success 1$/) + expect(response.body).to match(/^db_ping_latency [0-9\.]+$/) + end + + it 'returns Redis ping metrics' do + get :index + + expect(response.body).to match(/^redis_ping_timeout 0$/) + expect(response.body).to match(/^redis_ping_success 1$/) + expect(response.body).to match(/^redis_ping_latency [0-9\.]+$/) + end + + it 'returns file system check metrics' do + get :index + + expect(response.body).to match(/^filesystem_access_latency{shard="default"} [0-9\.]+$/) + expect(response.body).to match(/^filesystem_accessible{shard="default"} 1$/) + expect(response.body).to match(/^filesystem_write_latency{shard="default"} [0-9\.]+$/) + expect(response.body).to match(/^filesystem_writable{shard="default"} 1$/) + expect(response.body).to match(/^filesystem_read_latency{shard="default"} [0-9\.]+$/) + expect(response.body).to match(/^filesystem_readable{shard="default"} 1$/) + end + + context 'prometheus metrics are disabled' do + before do + allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(false) + end + + it 'returns proper response' do + get :index + + expect(response.status).to eq(404) + end + end + end + + context 'without authorization token' do + it 'returns proper response' do + get :index + + expect(response.status).to eq(404) + end + end + end +end diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb index 61e4fae46fb..363ed410bc0 100644 --- a/spec/controllers/profiles/keys_controller_spec.rb +++ b/spec/controllers/profiles/keys_controller_spec.rb @@ -49,7 +49,7 @@ describe Profiles::KeysController do expect(response.body).to eq(user.all_ssh_keys.join("\n")) expect(response.body).to include(key.key.sub(' dummy@gitlab.com', '')) - expect(response.body).to include(another_key.key) + expect(response.body).to include(another_key.key.sub(' dummy@gitlab.com', '')) expect(response.body).not_to include(deploy_key.key) end diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb new file mode 100644 index 00000000000..9d60dab12d1 --- /dev/null +++ b/spec/controllers/profiles_controller_spec.rb @@ -0,0 +1,31 @@ +require('spec_helper') + +describe ProfilesController do + describe "PUT update" do + it "allows an email update from a user without an external email address" do + user = create(:user) + sign_in(user) + + put :update, + user: { email: "john@gmail.com", name: "John" } + + user.reload + + expect(response.status).to eq(302) + expect(user.unconfirmed_email).to eq('john@gmail.com') + end + + it "ignores an email update from a user with an external email address" do + ldap_user = create(:omniauth_user, external_email: true) + sign_in(ldap_user) + + put :update, + user: { email: "john@gmail.com", name: "John" } + + ldap_user.reload + + expect(response.status).to eq(302) + expect(ldap_user.unconfirmed_email).not_to eq('john@gmail.com') + end + end +end diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb index eff9fab8da2..428bc45b842 100644 --- a/spec/controllers/projects/artifacts_controller_spec.rb +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -12,7 +12,7 @@ describe Projects::ArtifactsController do status: 'success') end - let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } before do project.team << [user, :developer] @@ -22,16 +22,16 @@ describe Projects::ArtifactsController do describe 'GET download' do it 'sends the artifacts file' do - expect(controller).to receive(:send_file).with(build.artifacts_file.path, disposition: 'attachment').and_call_original + expect(controller).to receive(:send_file).with(job.artifacts_file.path, disposition: 'attachment').and_call_original - get :download, namespace_id: project.namespace, project_id: project, build_id: build + get :download, namespace_id: project.namespace, project_id: project, job_id: job end end describe 'GET browse' do context 'when the directory exists' do it 'renders the browse view' do - get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'other_artifacts_0.1.2' + get :browse, namespace_id: project.namespace, project_id: project, job_id: job, path: 'other_artifacts_0.1.2' expect(response).to render_template('projects/artifacts/browse') end @@ -39,7 +39,7 @@ describe Projects::ArtifactsController do context 'when the directory does not exist' do it 'responds Not Found' do - get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + get :browse, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown' expect(response).to be_not_found end @@ -49,7 +49,7 @@ describe Projects::ArtifactsController do describe 'GET file' do context 'when the file exists' do it 'renders the file view' do - get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + get :file, namespace_id: project.namespace, project_id: project, job_id: job, path: 'ci_artifacts.txt' expect(response).to render_template('projects/artifacts/file') end @@ -57,7 +57,7 @@ describe Projects::ArtifactsController do context 'when the file does not exist' do it 'responds Not Found' do - get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + get :file, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown' expect(response).to be_not_found end @@ -67,7 +67,7 @@ describe Projects::ArtifactsController do describe 'GET raw' do context 'when the file exists' do it 'serves the file using workhorse' do - get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + get :raw, namespace_id: project.namespace, project_id: project, job_id: job, path: 'ci_artifacts.txt' send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER] @@ -84,7 +84,7 @@ describe Projects::ArtifactsController do context 'when the file does not exist' do it 'responds Not Found' do - get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + get :raw, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown' expect(response).to be_not_found end @@ -92,29 +92,29 @@ describe Projects::ArtifactsController do end describe 'GET latest_succeeded' do - def params_from_ref(ref = pipeline.ref, job = build.name, path = 'browse') + def params_from_ref(ref = pipeline.ref, job_name = job.name, path = 'browse') { namespace_id: project.namespace, project_id: project, ref_name_and_path: File.join(ref, path), - job: job + job: job_name } end - context 'cannot find the build' do + context 'cannot find the job' 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 :latest_succeeded, params_from_ref('TAIL', build.name) + get :latest_succeeded, params_from_ref('TAIL', job.name) end it_behaves_like 'not found' end - context 'has no such build' do + context 'has no such job' do before do get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD') end @@ -124,20 +124,20 @@ describe Projects::ArtifactsController do context 'has no path' do before do - get :latest_succeeded, params_from_ref(pipeline.sha, build.name, '') + get :latest_succeeded, params_from_ref(pipeline.sha, job.name, '') end it_behaves_like 'not found' end end - context 'found the build and redirect' do - shared_examples 'redirect to the build' do + context 'found the job and redirect' do + shared_examples 'redirect to the job' do it 'redirects' do - path = browse_namespace_project_build_artifacts_path( + path = browse_namespace_project_job_artifacts_path( project.namespace, project, - build) + job) expect(response).to redirect_to(path) end @@ -151,7 +151,7 @@ describe Projects::ArtifactsController do get :latest_succeeded, params_from_ref('master') end - it_behaves_like 'redirect to the build' + it_behaves_like 'redirect to the job' end context 'with branch name containing slash' do @@ -162,7 +162,7 @@ describe Projects::ArtifactsController do get :latest_succeeded, params_from_ref('improve/awesome') end - it_behaves_like 'redirect to the build' + it_behaves_like 'redirect to the job' end context 'with branch name and path containing slashes' do @@ -170,14 +170,14 @@ describe Projects::ArtifactsController do pipeline.update(ref: 'improve/awesome', sha: project.commit('improve/awesome').sha) - get :latest_succeeded, params_from_ref('improve/awesome', build.name, 'file/README.md') + get :latest_succeeded, params_from_ref('improve/awesome', job.name, 'file/README.md') end it 'redirects' do - path = file_namespace_project_build_artifacts_path( + path = file_namespace_project_job_artifacts_path( project.namespace, project, - build, + job, 'README.md') expect(response).to redirect_to(path) diff --git a/spec/controllers/projects/boards/lists_controller_spec.rb b/spec/controllers/projects/boards/lists_controller_spec.rb index 432f3c53c90..0f2664262e8 100644 --- a/spec/controllers/projects/boards/lists_controller_spec.rb +++ b/spec/controllers/projects/boards/lists_controller_spec.rb @@ -27,7 +27,7 @@ describe Projects::Boards::ListsController do parsed_response = JSON.parse(response.body) expect(response).to match_response_schema('lists') - expect(parsed_response.length).to eq 2 + expect(parsed_response.length).to eq 3 end context 'with unauthorized user' do diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb index 3de38bb4dac..4d6db81415e 100644 --- a/spec/controllers/projects/deployments_controller_spec.rb +++ b/spec/controllers/projects/deployments_controller_spec.rb @@ -42,6 +42,7 @@ describe Projects::DeploymentsController do before do allow(controller).to receive(:deployment).and_return(deployment) end +<<<<<<< HEAD context 'when environment has no metrics' do before do @@ -75,6 +76,70 @@ describe Projects::DeploymentsController do expect(json_response['success']).to be(true) expect(json_response['metrics']).to eq({}) expect(json_response['last_update']).to eq(42) +======= + context 'when metrics are disabled' do + before do + allow(deployment).to receive(:has_metrics?).and_return false + end + + it 'responds with not found' do + get :metrics, deployment_params(id: deployment.id) + + expect(response).to be_not_found + end + end + + context 'when metrics are enabled' do + before do + allow(deployment).to receive(:has_metrics?).and_return true + end + + context 'when environment has no metrics' do + before do + expect(deployment).to receive(:metrics).and_return(nil) + end + + it 'returns a empty response 204 resposne' do + get :metrics, deployment_params(id: deployment.id) + expect(response).to have_http_status(204) + expect(response.body).to eq('') + end + end + + context 'when environment has some metrics' do + let(:empty_metrics) do + { + success: true, + metrics: {}, + last_update: 42 + } + end + + before do + expect(deployment).to receive(:metrics).and_return(empty_metrics) + end + + it 'returns a metrics JSON document' do + get :metrics, deployment_params(id: deployment.id) + + expect(response).to be_ok + expect(json_response['success']).to be(true) + expect(json_response['metrics']).to eq({}) + expect(json_response['last_update']).to eq(42) + end + end + + context 'when metrics service does not implement deployment metrics' do + before do + allow(deployment).to receive(:metrics).and_raise(NotImplementedError) + end + + it 'responds with not found' do + get :metrics, deployment_params(id: deployment.id) + + expect(response).to be_not_found + end +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end end diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index c0f8c36a018..5b87962afe3 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -1,25 +1,25 @@ require 'spec_helper' describe Projects::EnvironmentsController do - let(:user) { create(:user) } - let(:project) { create(:empty_project) } + set(:user) { create(:user) } + set(:project) { create(:empty_project) } - let(:environment) do + set(:environment) do create(:environment, name: 'production', project: project) end before do - project.team << [user, :master] + project.add_master(user) sign_in(user) end describe 'GET index' do - context 'when standardrequest has been made' do + context 'when a request for the HTML is made' do it 'responds with status code 200' do get :index, environment_params - expect(response).to be_ok + expect(response).to have_http_status(:ok) end end @@ -57,6 +57,11 @@ describe Projects::EnvironmentsController do expect(json_response['available_count']).to eq 3 expect(json_response['stopped_count']).to eq 1 end + + it 'sets the polling interval header' do + expect(response).to have_http_status(:ok) + expect(response.headers['Poll-Interval']).to eq("3000") + end end context 'when requesting stopped environments scope' do @@ -84,6 +89,9 @@ describe Projects::EnvironmentsController do create(:environment, project: project, name: 'staging-1.0/review', state: :available) + create(:environment, project: project, + name: 'staging-1.0/zzz', + state: :available) end context 'when using default format' do @@ -98,7 +106,7 @@ describe Projects::EnvironmentsController do end context 'when using JSON format' do - it 'responds with JSON' do + it 'sorts the subfolders lexicographically' do get :folder, namespace_id: project.namespace, project_id: project, id: 'staging-1.0', @@ -108,6 +116,8 @@ describe Projects::EnvironmentsController do expect(response).not_to render_template 'folder' expect(json_response['environments'][0]) .to include('name' => 'staging-1.0/review') + expect(json_response['environments'][1]) + .to include('name' => 'staging-1.0/zzz') end end end @@ -172,7 +182,11 @@ describe Projects::EnvironmentsController do expect(response).to have_http_status(200) expect(json_response).to eq( { 'redirect_url' => +<<<<<<< HEAD "http://test.host/#{project.path_with_namespace}/builds/#{action.id}" }) +======= + namespace_project_job_url(project.namespace, project, action) }) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end @@ -186,7 +200,11 @@ describe Projects::EnvironmentsController do expect(response).to have_http_status(200) expect(json_response).to eq( { 'redirect_url' => +<<<<<<< HEAD "http://test.host/#{project.path_with_namespace}/environments/#{environment.id}" }) +======= + namespace_project_environment_url(project.namespace, project, environment) }) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 1f79e72495a..a38ae2eb990 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -156,6 +156,32 @@ describe Projects::IssuesController do end end + describe 'Redirect after sign in' do + context 'with an AJAX request' do + it 'does not store the visited URL' do + xhr :get, + :show, + format: :json, + namespace_id: project.namespace, + project_id: project, + id: issue.iid + + expect(session['user_return_to']).to be_blank + end + end + + context 'without an AJAX request' do + it 'stores the visited URL' do + get :show, + namespace_id: project.namespace.to_param, + project_id: project, + id: issue.iid + + expect(session['user_return_to']).to eq("/#{project.namespace.to_param}/#{project.to_param}/issues/#{issue.iid}") + end + end + end + describe 'PUT #update' do before do sign_in(user) @@ -178,7 +204,7 @@ describe Projects::IssuesController do body = JSON.parse(response.body) expect(body['assignees'].first.keys) - .to match_array(%w(id name username avatar_url)) + .to match_array(%w(id name username avatar_url state web_url)) end end diff --git a/spec/controllers/projects/builds_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index 3ce23c17cdc..7211acc53dc 100644 --- a/spec/controllers/projects/builds_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Projects::BuildsController do +describe Projects::JobsController do include ApiHelpers let(:project) { create(:empty_project, :public) } @@ -101,26 +101,49 @@ describe Projects::BuildsController do end describe 'GET show' do - context 'when build exists' do - let!(:build) { create(:ci_build, pipeline: pipeline) } + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } - before do - get_show(id: build.id) + context 'when requesting HTML' do + context 'when build exists' do + before do + get_show(id: build.id) + end + + it 'has a build' do + expect(response).to have_http_status(:ok) + expect(assigns(:build).id).to eq(build.id) + end end - it 'has a build' do - expect(response).to have_http_status(:ok) - expect(assigns(:build).id).to eq(build.id) + context 'when build does not exist' do + before do + get_show(id: 1234) + end + + it 'renders not_found' do + expect(response).to have_http_status(:not_found) + end end end - context 'when build does not exist' do + context 'when requesting JSON' do + let(:merge_request) { create(:merge_request, source_project: project) } + before do - get_show(id: 1234) + project.add_developer(user) + sign_in(user) + + allow_any_instance_of(Ci::Build).to receive(:merge_request).and_return(merge_request) + + get_show(id: build.id, format: :json) end - it 'renders not_found' do - expect(response).to have_http_status(:not_found) + it 'exposes needed information' do + expect(response).to have_http_status(:ok) + expect(json_response['raw_path']).to match(/builds\/\d+\/raw\z/) + expect(json_response.dig('merge_request', 'path')).to match(/merge_requests\/\d+\z/) + expect(json_response['new_issue_path']) + .to include('/issues/new') end end @@ -144,6 +167,8 @@ describe Projects::BuildsController do it 'returns a trace' do expect(response).to have_http_status(:ok) + expect(json_response['id']).to eq build.id + expect(json_response['status']).to eq build.status expect(json_response['html']).to eq('BUILD TRACE') end end @@ -153,10 +178,23 @@ describe Projects::BuildsController do it 'returns no traces' do expect(response).to have_http_status(:ok) + expect(json_response['id']).to eq build.id + expect(json_response['status']).to eq build.status expect(json_response['html']).to be_nil end end + context 'when build has a trace with ANSI sequence and Unicode' do + let(:build) { create(:ci_build, :unicode_trace, pipeline: pipeline) } + + it 'returns a trace with Unicode' do + expect(response).to have_http_status(:ok) + expect(json_response['id']).to eq build.id + expect(json_response['status']).to eq build.status + expect(json_response['html']).to include("ヾ(´༎ຶД༎ຶ`)ノ") + end + end + def get_trace get :trace, namespace_id: project.namespace, project_id: project, @@ -185,48 +223,6 @@ describe Projects::BuildsController do end end - describe 'GET trace.json' do - let(:pipeline) { create(:ci_pipeline, project: project) } - let(:build) { create(:ci_build, pipeline: pipeline) } - let(:user) { create(:user) } - - context 'when user is logged in as developer' do - before do - project.add_developer(user) - sign_in(user) - - get_trace - end - - it 'traces build log' do - expect(response).to have_http_status(:ok) - expect(json_response['id']).to eq build.id - expect(json_response['status']).to eq build.status - end - end - - context 'when user is logged in as non member' do - before do - sign_in(user) - - get_trace - end - - it 'traces build log' do - expect(response).to have_http_status(:ok) - expect(json_response['id']).to eq build.id - expect(json_response['status']).to eq build.status - end - end - - def get_trace - get :trace, namespace_id: project.namespace, - project_id: project, - id: build.id, - format: :json - end - end - describe 'POST retry' do before do project.add_developer(user) @@ -240,7 +236,7 @@ describe Projects::BuildsController do it 'redirects to the retried build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: Ci::Build.last.id)) + expect(response).to redirect_to(namespace_project_job_path(id: Ci::Build.last.id)) end end @@ -261,7 +257,11 @@ describe Projects::BuildsController do describe 'POST play' do before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) + sign_in(user) post_play @@ -272,7 +272,7 @@ describe Projects::BuildsController do it 'redirects to the played build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: build.id)) + expect(response).to redirect_to(namespace_project_job_path(id: build.id)) end it 'transits to pending' do @@ -308,7 +308,7 @@ describe Projects::BuildsController do it 'redirects to the canceled build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: build.id)) + expect(response).to redirect_to(namespace_project_job_path(id: build.id)) end it 'transits to canceled' do @@ -346,7 +346,7 @@ describe Projects::BuildsController do it 'redirects to a index page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_builds_path) + expect(response).to redirect_to(namespace_project_jobs_path) end it 'transits to canceled' do @@ -363,7 +363,7 @@ describe Projects::BuildsController do it 'redirects to a index page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_builds_path) + expect(response).to redirect_to(namespace_project_jobs_path) end end @@ -386,7 +386,7 @@ describe Projects::BuildsController do it 'redirects to the erased build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: build.id)) + expect(response).to redirect_to(namespace_project_job_path(id: build.id)) end it 'erases artifacts' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 8192f3e6fb6..554fac3191f 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -126,7 +126,11 @@ describe Projects::MergeRequestsController do recorded = ActiveRecord::QueryRecorder.new { go(format: :json) } +<<<<<<< HEAD expect(recorded.count).to be_within(1).of(51) +======= + expect(recorded.count).to be_within(5).of(50) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(recorded.cached_count).to eq(0) end end @@ -358,7 +362,7 @@ describe Projects::MergeRequestsController do end before do - create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) + create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch, head_pipeline_of: merge_request) end it 'returns :merge_when_pipeline_succeeds' do @@ -1175,12 +1179,15 @@ describe Projects::MergeRequestsController do let!(:pipeline) do create(:ci_pipeline, project: merge_request.source_project, ref: merge_request.source_branch, - sha: merge_request.diff_head_sha) + sha: merge_request.diff_head_sha, + head_pipeline_of: merge_request) end let(:status) { pipeline.detailed_status(double('user')) } - before { get_pipeline_status } + before do + get_pipeline_status + end it 'return a detailed head_pipeline status in json' do expect(response).to have_http_status(:ok) diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index fb4a4721a58..c880da1e36a 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -38,7 +38,7 @@ describe Projects::PipelinesController do end describe 'GET show JSON' do - let!(:pipeline) { create(:ci_pipeline_with_one_job, project: project) } + let(:pipeline) { create(:ci_pipeline_with_one_job, project: project) } it 'returns the pipeline' do get_pipeline_json @@ -49,20 +49,48 @@ describe Projects::PipelinesController do expect(json_response['details']).to have_key 'stages' end - context 'when the pipeline has multiple jobs' do + context 'when the pipeline has multiple stages and groups' do + before do + RequestStore.begin! + + create_build('build', 0, 'build') + create_build('test', 1, 'rspec 0') + create_build('deploy', 2, 'production') + create_build('post deploy', 3, 'pages 0') + end + + after do + RequestStore.end! + RequestStore.clear! + end + + let(:project) { create(:project) } + let(:pipeline) do + create(:ci_empty_pipeline, project: project, user: user, sha: project.commit.id) + end + it 'does not perform N + 1 queries' do control_count = ActiveRecord::QueryRecorder.new { get_pipeline_json }.count - create(:ci_build, pipeline: pipeline) + create_build('test', 1, 'rspec 1') + create_build('test', 1, 'spinach 0') + create_build('test', 1, 'spinach 1') + create_build('test', 1, 'audit') + create_build('post deploy', 3, 'pages 1') + create_build('post deploy', 3, 'pages 2') - # The plus 2 is needed to group and sort - expect { get_pipeline_json }.not_to exceed_query_limit(control_count + 2) + new_count = ActiveRecord::QueryRecorder.new { get_pipeline_json }.count + expect(new_count).to be_within(12).of(control_count) end end def get_pipeline_json get :show, namespace_id: project.namespace, project_id: project, id: pipeline, format: :json end + + def create_build(stage, stage_idx, name) + create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name) + end end describe 'GET stages.json' do diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index a4b4392d7cc..2294d5df581 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -36,7 +36,7 @@ describe Projects::ProjectMembersController do before { project.team << [user, :master] } it 'adds user to members' do - expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(true) + expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(status: :success) post :create, namespace_id: project.namespace, project_id: project, @@ -48,14 +48,14 @@ describe Projects::ProjectMembersController do end it 'adds no user to members' do - expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(false) + expect_any_instance_of(Members::CreateService).to receive(:execute).and_return(status: :failure, message: 'Message') post :create, namespace_id: project.namespace, project_id: project, user_ids: '', access_level: Gitlab::Access::GUEST - expect(response).to set_flash.to 'No users specified.' + expect(response).to set_flash.to 'Message' expect(response).to redirect_to(namespace_project_settings_members_path(project.namespace, project)) end end diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index 2d892f4a2b7..23b463c0082 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -3,7 +3,9 @@ require 'spec_helper' describe Projects::ServicesController do let(:project) { create(:project, :repository) } let(:user) { create(:user) } - let(:service) { create(:service, project: project) } + let(:service) { create(:hipchat_service, project: project) } + let(:hipchat_client) { { '#room' => double(send: true) } } + let(:service_params) { { token: 'hipchat_token_p', room: '#room' } } before do sign_in(user) @@ -13,97 +15,81 @@ describe Projects::ServicesController do controller.instance_variable_set(:@service, service) end - shared_examples_for 'services controller' do |referrer| - before do - request.env["HTTP_REFERER"] = referrer - end - - describe "#test" do - context 'when can_test? returns false' do - it 'renders 404' do - allow_any_instance_of(Service).to receive(:can_test?).and_return(false) + describe '#test' do + context 'when can_test? returns false' do + it 'renders 404' do + allow_any_instance_of(Service).to receive(:can_test?).and_return(false) - get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id - expect(response).to have_http_status(404) - end + expect(response).to have_http_status(404) end + end - context 'success' do - context 'with empty project' do - let(:project) { create(:empty_project) } - - context 'with chat notification service' do - let(:service) { project.create_microsoft_teams_service(webhook: 'http://webhook.com') } - - it 'redirects and show success message' do - allow_any_instance_of(MicrosoftTeams::Notifier).to receive(:ping).and_return(true) - - get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + context 'success' do + context 'with empty project' do + let(:project) { create(:empty_project) } - expect(response).to redirect_to(root_path) - expect(flash[:notice]).to eq('We sent a request to the provided URL') - end - end + context 'with chat notification service' do + let(:service) { project.create_microsoft_teams_service(webhook: 'http://webhook.com') } - it 'redirects and show success message' do - expect(service).to receive(:test).and_return(success: true, result: 'done') + it 'returns success' do + allow_any_instance_of(MicrosoftTeams::Notifier).to receive(:ping).and_return(true) - get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id - expect(response).to redirect_to(root_path) - expect(flash[:notice]).to eq('We sent a request to the provided URL') + expect(response.status).to eq(200) end end - it "redirects and show success message" do - expect(service).to receive(:test).and_return(success: true, result: 'done') + it 'returns success' do + expect(HipChat::Client).to receive(:new).with('hipchat_token_p', anything).and_return(hipchat_client) - get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: service_params - expect(response).to redirect_to(root_path) - expect(flash[:notice]).to eq('We sent a request to the provided URL') + expect(response.status).to eq(200) end end - context 'failure' do - it "redirects and show failure message" do - expect(service).to receive(:test).and_return(success: false, result: 'Bad test') + it 'returns success' do + expect(HipChat::Client).to receive(:new).with('hipchat_token_p', anything).and_return(hipchat_client) - get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: service_params - expect(response).to redirect_to(root_path) - expect(flash[:alert]).to eq('We tried to send a request to the provided URL but an error occurred: Bad test') - end + expect(response.status).to eq(200) end end - end - describe 'referrer defined' do - it_should_behave_like 'services controller' do - let!(:referrer) { "/" } - end - end + context 'failure' do + it 'returns success status code and the error message' do + expect(HipChat::Client).to receive(:new).with('hipchat_token_p', anything).and_raise('Bad test') - describe 'referrer undefined' do - it_should_behave_like 'services controller' do - let!(:referrer) { nil } + put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: service_params + + expect(response.status).to eq(200) + expect(JSON.parse(response.body)). + to eq('error' => true, 'message' => 'Test failed.', 'service_response' => 'Bad test') + end 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') - expect(service).to receive(:event_names).and_return(HipchatService.event_names) + context 'when param `active` is set to true' do + it 'activates the service and redirects to integrations paths' do + put :update, + namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: { active: true } + + expect(response).to redirect_to(namespace_project_settings_integrations_path(project.namespace, project)) + expect(flash[:notice]).to eq 'HipChat activated.' + end + end + context 'when param `active` is set to false' do + it 'does not activate the service but saves the settings' do put :update, - namespace_id: project.namespace.id, - project_id: project.id, - id: service.id, - service: { active: false } + namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: { active: false } - expect(flash[:notice]).to eq 'Successfully updated.' + expect(flash[:notice]).to eq 'HipChat settings saved, but not activated.' end end end diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb index 24a59caff4e..8c23c46798e 100644 --- a/spec/controllers/projects/snippets_controller_spec.rb +++ b/spec/controllers/projects/snippets_controller_spec.rb @@ -78,8 +78,18 @@ describe Projects::SnippetsController do post :create, { namespace_id: project.namespace.to_param, project_id: project, - project_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params) + project_snippet: { title: 'Title', content: 'Content', description: 'Description' }.merge(snippet_params) }.merge(additional_params) + + Snippet.last + end + + it 'creates the snippet correctly' do + snippet = create_snippet(project, visibility_level: Snippet::PRIVATE) + + expect(snippet.title).to eq('Title') + expect(snippet.content).to eq('Content') + expect(snippet.description).to eq('Description') end context 'when the snippet is spam' do diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index a8be6768a47..eda46dc8421 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -224,6 +224,53 @@ describe ProjectsController do expect(assigns(:repository).path).to eq(project.repository.path) expect(response).to have_http_status(302) end +<<<<<<< HEAD +======= + end + + describe '#transfer' do + render_views + + let(:project) { create(:project) } + let(:admin) { create(:admin) } + let(:new_namespace) { create(:namespace) } + + it 'updates namespace' do + sign_in(admin) + + put :transfer, + namespace_id: project.namespace.path, + new_namespace_id: new_namespace.id, + id: project.path, + format: :js + + project.reload + + expect(project.namespace).to eq(new_namespace) + expect(response).to have_http_status(200) + end + + context 'when new namespace is empty' do + it 'project namespace is not changed' do + controller.instance_variable_set(:@project, project) + sign_in(admin) + + old_namespace = project.namespace + + put :transfer, + namespace_id: old_namespace.path, + new_namespace_id: nil, + id: project.path, + format: :js + + project.reload + + expect(project.namespace).to eq(old_namespace) + expect(response).to have_http_status(200) + expect(flash[:alert]).to eq 'Please select a new namespace for your project.' + end + end +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end describe "#destroy" do diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index 71dd9ef3eb4..634563fc290 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -77,7 +77,7 @@ describe RegistrationsController do end it 'schedules the user for destruction' do - expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id) + expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id, {}) post(:destroy) diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 038132cffe0..e87e24a33a1 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -1,6 +1,37 @@ require 'spec_helper' describe SessionsController do + describe '#new' do + before do + @request.env['devise.mapping'] = Devise.mappings[:user] + end + + context 'when auto sign-in is enabled' do + before do + stub_omniauth_setting(auto_sign_in_with_provider: :saml) + allow(controller).to receive(:omniauth_authorize_path).with(:user, :saml). + and_return('/saml') + end + + context 'and no auto_sign_in param is passed' do + it 'redirects to :omniauth_authorize_path' do + get(:new) + + expect(response).to have_http_status(302) + expect(response).to redirect_to('/saml') + end + end + + context 'and auto_sign_in=false param is passed' do + it 'responds with 200' do + get(:new, auto_sign_in: 'false') + + expect(response).to have_http_status(200) + end + end + end + end + describe '#create' do before do @request.env['devise.mapping'] = Devise.mappings[:user] diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index 930415a4778..9073c39f562 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -171,12 +171,50 @@ describe SnippetsController do sign_in(user) post :create, { - personal_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params) + personal_snippet: { title: 'Title', content: 'Content', description: 'Description' }.merge(snippet_params) }.merge(additional_params) Snippet.last end + it 'creates the snippet correctly' do + snippet = create_snippet(visibility_level: Snippet::PRIVATE) + + expect(snippet.title).to eq('Title') + expect(snippet.content).to eq('Content') + expect(snippet.description).to eq('Description') + end + + context 'when the snippet description contains a file' do + let(:picture_file) { '/temp/secret56/picture.jpg' } + let(:text_file) { '/temp/secret78/text.txt' } + let(:description) do + "Description with picture:  and "\ + "text: [text.txt](/uploads#{text_file})" + end + + before do + allow(FileUtils).to receive(:mkdir_p) + allow(FileUtils).to receive(:move) + end + + subject { create_snippet({ description: description }, { files: [picture_file, text_file] }) } + + it 'creates the snippet' do + expect { subject }.to change { Snippet.count }.by(1) + end + + it 'stores the snippet description correctly' do + snippet = subject + + expected_description = "Description with picture: "\ + " and "\ + "text: [text.txt](/uploads/personal_snippet/#{snippet.id}/secret78/text.txt)" + + expect(snippet.description).to eq(expected_description) + end + end + context 'when the snippet is spam' do before do allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true) diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 8000c9dec61..01a0659479b 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -92,6 +92,40 @@ describe UploadsController do end end end + + context 'temporal with valid image' do + subject do + post :create, model: 'personal_snippet', file: jpg, format: :json + end + + it 'returns a content with original filename, new link, and correct type.' do + subject + + expect(response.body).to match '\"alt\":\"rails_sample\"' + expect(response.body).to match "\"url\":\"/uploads/temp" + end + + it 'does not create an Upload record' do + expect { subject }.not_to change { Upload.count } + end + end + + context 'temporal with valid non-image file' do + subject do + post :create, model: 'personal_snippet', file: txt, format: :json + end + + it 'returns a content with original filename, new link, and correct type.' do + subject + + expect(response.body).to match '\"alt\":\"doc_sample.txt\"' + expect(response.body).to match "\"url\":\"/uploads/temp" + end + + it 'does not create an Upload record' do + expect { subject }.not_to change { Upload.count } + end + end end end diff --git a/spec/db/production/settings.rb b/spec/db/production/settings.rb deleted file mode 100644 index 007b35bbb77..00000000000 --- a/spec/db/production/settings.rb +++ /dev/null @@ -1,17 +0,0 @@ -require 'spec_helper' -require 'rainbow/ext/string' - -describe 'seed production settings', lib: true do - include StubENV - - context 'GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN is set in the environment' do - before do - stub_env('GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN', '013456789') - end - - it 'writes the token to the database' do - load(File.join(__dir__, '../../../db/fixtures/production/010_settings.rb')) - expect(ApplicationSetting.current.runners_registration_token).to eq('013456789') - end - end -end diff --git a/spec/db/production/settings_spec.rb b/spec/db/production/settings_spec.rb new file mode 100644 index 00000000000..a9d015e0666 --- /dev/null +++ b/spec/db/production/settings_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' +require 'rainbow/ext/string' + +describe 'seed production settings', lib: true do + include StubENV + let(:settings_file) { Rails.root.join('db/fixtures/production/010_settings.rb') } + let(:settings) { Gitlab::CurrentSettings.current_application_settings } + + context 'GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN is set in the environment' do + before do + stub_env('GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN', '013456789') + end + + it 'writes the token to the database' do + load(settings_file) + + expect(settings.runners_registration_token).to eq('013456789') + end + end + + context 'GITLAB_PROMETHEUS_METRICS_ENABLED is set in the environment' do + context 'GITLAB_PROMETHEUS_METRICS_ENABLED is true' do + before do + stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', 'true') + end + + it 'prometheus_metrics_enabled is set to true ' do + load(settings_file) + + expect(settings.prometheus_metrics_enabled).to eq(true) + end + end + + context 'GITLAB_PROMETHEUS_METRICS_ENABLED is false' do + before do + stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', 'false') + end + + it 'prometheus_metrics_enabled is set to false' do + load(settings_file) + + expect(settings.prometheus_metrics_enabled).to eq(false) + end + end + + context 'GITLAB_PROMETHEUS_METRICS_ENABLED is false' do + before do + stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', '') + end + + it 'prometheus_metrics_enabled is set to false' do + load(settings_file) + + expect(settings.prometheus_metrics_enabled).to eq(false) + end + end + end +end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 78ddd8d5584..0bb5a86d9b9 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -64,7 +64,8 @@ FactoryGirl.define do trait :teardown_environment do environment 'staging' options environment: { name: 'staging', - action: 'stop' } + action: 'stop', + url: 'http://staging.example.com/$CI_JOB_NAME' } end trait :allowed_to_fail do @@ -128,6 +129,16 @@ FactoryGirl.define do end end + trait :unicode_trace do + after(:create) do |build, evaluator| + trace = File.binread( + File.expand_path( + Rails.root.join('spec/fixtures/trace/ansi-sequence-and-unicode'))) + + build.trace.set(trace) + end + end + trait :erased do erased_at Time.now erased_by factory: :user diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 561fbc8e247..35803f0c37f 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -1,5 +1,6 @@ FactoryGirl.define do factory :ci_empty_pipeline, class: Ci::Pipeline do + source :push ref 'master' sha '97de212e80737a608d939f648d959671fb0a0142' status 'pending' @@ -8,33 +9,39 @@ FactoryGirl.define do factory :ci_pipeline_without_jobs do after(:build) do |pipeline| - allow(pipeline).to receive(:ci_yaml_file) { YAML.dump({}) } + pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump({})) end end factory :ci_pipeline_with_one_job do after(:build) do |pipeline| allow(pipeline).to receive(:ci_yaml_file) do - YAML.dump({ rspec: { script: "ls" } }) + pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump({ rspec: { script: "ls" } })) end end end + # Persist merge request head_pipeline_id + # on pipeline factories to avoid circular references + transient { head_pipeline_of nil } + + after(:create) do |pipeline, evaluator| + merge_request = evaluator.head_pipeline_of + merge_request&.update(head_pipeline: pipeline) + end + factory :ci_pipeline do transient { config nil } after(:build) do |pipeline, evaluator| - allow(pipeline).to receive(:ci_yaml_file) do - if evaluator.config - YAML.dump(evaluator.config) - else - File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) - end - end + if evaluator.config + pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump(evaluator.config)) - # Populates pipeline with errors - # - pipeline.config_processor if evaluator.config + # Populates pipeline with errors + pipeline.config_processor if evaluator.config + else + pipeline.instance_variable_set(:@ci_yaml_file, File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))) + end end trait :invalid do diff --git a/spec/factories/ci/stages.rb b/spec/factories/ci/stages.rb index 7f557b25ccb..d3c8bf9d54f 100644 --- a/spec/factories/ci/stages.rb +++ b/spec/factories/ci/stages.rb @@ -1,5 +1,7 @@ FactoryGirl.define do - factory :ci_stage, class: Ci::Stage do + factory :ci_stage, class: Ci::LegacyStage do + skip_create + transient do name 'test' status nil @@ -8,7 +10,9 @@ FactoryGirl.define do end initialize_with do - Ci::Stage.new(pipeline, name: name, status: status, warnings: warnings) + Ci::LegacyStage.new(pipeline, name: name, + status: status, + warnings: warnings) end end end diff --git a/spec/factories/ci/trigger_requests.rb b/spec/factories/ci/trigger_requests.rb index b8d8fab0e0b..10e0ab4fd3c 100644 --- a/spec/factories/ci/trigger_requests.rb +++ b/spec/factories/ci/trigger_requests.rb @@ -1,8 +1,8 @@ FactoryGirl.define do factory :ci_trigger_request, class: Ci::TriggerRequest do - factory :ci_trigger_request_with_variables do - trigger factory: :ci_trigger + trigger factory: :ci_trigger + factory :ci_trigger_request_with_variables do variables do { TRIGGER_KEY_1: 'TRIGGER_VALUE_1', diff --git a/spec/factories/ci/variables.rb b/spec/factories/ci/variables.rb index 6653f0bb5c3..f83366136fd 100644 --- a/spec/factories/ci/variables.rb +++ b/spec/factories/ci/variables.rb @@ -2,5 +2,11 @@ FactoryGirl.define do factory :ci_variable, class: Ci::Variable do sequence(:key) { |n| "VARIABLE_#{n}" } value 'VARIABLE_VALUE' + + trait(:protected) do + protected true + end + + project factory: :empty_project end end diff --git a/spec/factories/commits.rb b/spec/factories/commits.rb index 89e260cf65b..36b9645438a 100644 --- a/spec/factories/commits.rb +++ b/spec/factories/commits.rb @@ -4,19 +4,14 @@ FactoryGirl.define do factory :commit do git_commit RepoHelpers.sample_commit project factory: :empty_project + author { build(:author) } initialize_with do new(git_commit, project) end - after(:build) do |commit| - allow(commit).to receive(:author).and_return build(:author) - end - trait :without_author do - after(:build) do |commit| - allow(commit).to receive(:author).and_return nil - end + author nil end end end diff --git a/spec/factories/conversational_development_index_metrics.rb b/spec/factories/conversational_development_index_metrics.rb new file mode 100644 index 00000000000..a5412629195 --- /dev/null +++ b/spec/factories/conversational_development_index_metrics.rb @@ -0,0 +1,33 @@ +FactoryGirl.define do + factory :conversational_development_index_metric, class: ConversationalDevelopmentIndex::Metric do + leader_issues 9.256 + instance_issues 1.234 + + leader_notes 30.33333 + instance_notes 28.123 + + leader_milestones 16.2456 + instance_milestones 1.234 + + leader_boards 5.2123 + instance_boards 3.254 + + leader_merge_requests 1.2 + instance_merge_requests 0.6 + + leader_ci_pipelines 12.1234 + instance_ci_pipelines 2.344 + + leader_environments 3.3333 + instance_environments 2.2222 + + leader_deployments 1.200 + instance_deployments 0.771 + + leader_projects_prometheus_active 0.111 + instance_projects_prometheus_active 0.109 + + leader_service_desk_issues 15.891 + instance_service_desk_issues 13.345 + end +end diff --git a/spec/factories/file_uploader.rb b/spec/factories/file_uploaders.rb index bc74aeecc3b..d397dd705a5 100644 --- a/spec/factories/file_uploader.rb +++ b/spec/factories/file_uploaders.rb @@ -1,5 +1,7 @@ FactoryGirl.define do factory :file_uploader do + skip_create + project factory: :empty_project secret nil diff --git a/spec/factories/forked_project_links.rb b/spec/factories/forked_project_links.rb index b16c1272e68..66b0f248959 100644 --- a/spec/factories/forked_project_links.rb +++ b/spec/factories/forked_project_links.rb @@ -7,5 +7,9 @@ FactoryGirl.define do link.forked_from_project.reload link.forked_to_project.reload end + + trait :forked_to_empty_project do + association :forked_to_project, factory: :empty_project + end end end diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb index 4e140102492..a13b6e3596e 100644 --- a/spec/factories/keys.rb +++ b/spec/factories/keys.rb @@ -1,27 +1,18 @@ +require_relative '../support/helpers/key_generator_helper' + FactoryGirl.define do factory :key do title - key do - 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0= dummy@gitlab.com' - end + key { Spec::Support::Helpers::KeyGeneratorHelper.new(1024).generate + ' dummy@gitlab.com' } - factory :deploy_key, class: 'DeployKey' do - key do - 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFf6RYK3qu/RKF/3ndJmL5xgMLp3O96x8lTay+QGZ0+9FnnAXMdUqBq/ZU6d/gyMB4IaW3nHzM1w049++yAB6UPCzMB8Uo27K5/jyZCtj7Vm9PFNjF/8am1kp46c/SeYicQgQaSBdzIW3UDEa1Ef68qroOlvpi9PYZ/tA7M0YP0K5PXX+E36zaIRnJVMPT3f2k+GnrxtjafZrwFdpOP/Fol5BQLBgcsyiU+LM1SuaCrzd8c9vyaTA1CxrkxaZh+buAi0PmdDtaDrHd42gqZkXCKavyvgM5o2CkQ5LJHCgzpXy05qNFzmThBSkb+XtoxbyagBiGbVZtSVow6Xa7qewz' - end - end + factory :deploy_key, class: 'DeployKey' factory :personal_key do user end factory :another_key do - key do - 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDmTillFzNTrrGgwaCKaSj+QCz81E6jBc/s9av0+3b1Hwfxgkqjl4nAK/OD2NjgyrONDTDfR8cRN4eAAy6nY8GLkOyYBDyuc5nTMqs5z3yVuTwf3koGm/YQQCmo91psZ2BgDFTor8SVEE5Mm1D1k3JDMhDFxzzrOtRYFPci9lskTJaBjpqWZ4E9rDTD2q/QZntCqbC3wE9uSemRQB5f8kik7vD/AD8VQXuzKladrZKkzkONCPWsXDspUitjM8HkQdOf0PsYn1CMUC1xKYbCxkg5TkEosIwGv6CoEArUrdu/4+10LVslq494mAvEItywzrluCLCnwELfW+h/m8UHoVhZ' - end - - factory :another_deploy_key, class: 'DeployKey' do - end + factory :another_deploy_key, class: 'DeployKey' end factory :write_access_key, class: 'DeployKey' do diff --git a/spec/factories/lists.rb b/spec/factories/lists.rb index f6a78811cbe..48142d3c49b 100644 --- a/spec/factories/lists.rb +++ b/spec/factories/lists.rb @@ -6,6 +6,12 @@ FactoryGirl.define do sequence(:position) end + factory :backlog_list, parent: :list do + list_type :backlog + label nil + position nil + end + factory :closed_list, parent: :list do list_type :closed label nil diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index 0210e871a63..cd754ea235f 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -14,7 +14,7 @@ FactoryGirl.define do issues_events true confidential_issues_events true note_events true - build_events true + job_events true pipeline_events true wiki_page_events true end diff --git a/spec/factories/project_statistics.rb b/spec/factories/project_statistics.rb index 72d43096216..6c2ed7c6581 100644 --- a/spec/factories/project_statistics.rb +++ b/spec/factories/project_statistics.rb @@ -1,6 +1,10 @@ FactoryGirl.define do factory :project_statistics do - project { create :project } - namespace { project.namespace } + project + + initialize_with do + # statistics are automatically created when a project is created + project&.statistics || new + end end end diff --git a/spec/factories/project_wikis.rb b/spec/factories/project_wikis.rb index a3403fd76ae..ae222d5e69a 100644 --- a/spec/factories/project_wikis.rb +++ b/spec/factories/project_wikis.rb @@ -1,5 +1,7 @@ FactoryGirl.define do factory :project_wiki do + skip_create + project factory: :empty_project user factory: :user initialize_with { new(project, user) } diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 574b52e760d..e17e50db143 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -1,3 +1,5 @@ +require_relative '../support/test_env' + FactoryGirl.define do # Project without repository # @@ -24,6 +26,22 @@ FactoryGirl.define do visibility_level Gitlab::VisibilityLevel::PRIVATE end + trait :import_scheduled do + import_status :scheduled + end + + trait :import_started do + import_status :started + end + + trait :import_finished do + import_status :finished + end + + trait :import_failed do + import_status :failed + end + trait :archived do archived true end @@ -60,7 +78,9 @@ FactoryGirl.define do trait :test_repo do after :create do |project| - TestEnv.copy_repo(project) + TestEnv.copy_repo(project, + bare_repo: TestEnv.factory_repo_path_bare, + refs: TestEnv::BRANCH_SHA) end end @@ -151,7 +171,9 @@ FactoryGirl.define do end after :create do |project, evaluator| - TestEnv.copy_repo(project) + TestEnv.copy_repo(project, + bare_repo: TestEnv.factory_repo_path_bare, + refs: TestEnv::BRANCH_SHA) if evaluator.create_template args = evaluator.create_template @@ -184,7 +206,9 @@ FactoryGirl.define do path { 'forked-gitlabhq' } after :create do |project| - TestEnv.copy_forked_repo_with_submodules(project) + TestEnv.copy_repo(project, + bare_repo: TestEnv.forked_repo_path_bare, + refs: TestEnv::FORKED_BRANCH_SHA) end end diff --git a/spec/factories/services.rb b/spec/factories/services.rb index 62aa71ae8d8..e7366a7fd1c 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -20,9 +20,8 @@ FactoryGirl.define do project factory: :empty_project active true properties({ - namespace: 'somepath', api_url: 'https://kubernetes.example.com', - token: 'a' * 40, + token: 'a' * 40 }) end @@ -34,4 +33,10 @@ FactoryGirl.define do project_key: 'jira-key' ) end + + factory :hipchat_service do + project factory: :empty_project + type 'HipchatService' + token 'test_token' + end end diff --git a/spec/factories/snippets.rb b/spec/factories/snippets.rb index 18cb0f5de26..388f662e6e5 100644 --- a/spec/factories/snippets.rb +++ b/spec/factories/snippets.rb @@ -3,6 +3,7 @@ FactoryGirl.define do author title { generate(:title) } content { generate(:title) } + description { generate(:title) } file_name { generate(:filename) } trait :public do diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 33fa80772ff..e60fe713bc3 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -8,6 +8,10 @@ FactoryGirl.define do confirmation_token { nil } can_create_group true + before(:create) do |user| + user.ensure_rss_token + end + trait :admin do admin true end diff --git a/spec/factories/web_hook_log.rb b/spec/factories/web_hook_log.rb new file mode 100644 index 00000000000..230b3f6b26e --- /dev/null +++ b/spec/factories/web_hook_log.rb @@ -0,0 +1,14 @@ +FactoryGirl.define do + factory :web_hook_log do + web_hook factory: :project_hook + trigger 'push_hooks' + url { generate(:url) } + request_headers {} + request_data {} + response_headers {} + response_body '' + response_status '200' + execution_duration 2.0 + internal_error_message nil + end +end diff --git a/spec/factories/wiki_directories.rb b/spec/factories/wiki_directories.rb index 3f3c864ac2b..3b4cfc380b8 100644 --- a/spec/factories/wiki_directories.rb +++ b/spec/factories/wiki_directories.rb @@ -1,5 +1,7 @@ FactoryGirl.define do factory :wiki_directory do + skip_create + slug '/path_up_to/dir' initialize_with { new(slug) } end diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb index 786e1456f5f..09b3c0b0994 100644 --- a/spec/factories_spec.rb +++ b/spec/factories_spec.rb @@ -3,14 +3,20 @@ require 'spec_helper' describe 'factories' do FactoryGirl.factories.each do |factory| describe "#{factory.name} factory" do - let(:entity) { build(factory.name) } + it 'does not raise error when built' do + expect { build(factory.name) }.not_to raise_error + end it 'does not raise error when created' do - expect { entity }.not_to raise_error + expect { create(factory.name) }.not_to raise_error end - it 'is valid', if: factory.build_class < ActiveRecord::Base do - expect(entity).to be_valid + factory.definition.defined_traits.map(&:name).each do |trait_name| + describe "linting #{trait_name} trait" do + skip 'does not raise error when created' do + expect { create(factory.name, trait_name) }.not_to raise_error + end + end end end end diff --git a/spec/features/admin/admin_builds_spec.rb b/spec/features/admin/admin_builds_spec.rb index 9d5ce876c29..999ce3611b5 100644 --- a/spec/features/admin/admin_builds_spec.rb +++ b/spec/features/admin/admin_builds_spec.rb @@ -16,7 +16,7 @@ describe 'Admin Builds' do create(:ci_build, pipeline: pipeline, status: :success) create(:ci_build, pipeline: pipeline, status: :failed) - visit admin_builds_path + visit admin_jobs_path expect(page).to have_selector('.nav-links li.active', text: 'All') expect(page).to have_selector('.row-content-block', text: 'All jobs') @@ -27,7 +27,7 @@ describe 'Admin Builds' do context 'when have no jobs' do it 'shows a message' do - visit admin_builds_path + visit admin_jobs_path expect(page).to have_selector('.nav-links li.active', text: 'All') expect(page).to have_content 'No jobs to show' @@ -44,7 +44,7 @@ describe 'Admin Builds' do build3 = create(:ci_build, pipeline: pipeline, status: :success) build4 = create(:ci_build, pipeline: pipeline, status: :failed) - visit admin_builds_path(scope: :pending) + visit admin_jobs_path(scope: :pending) expect(page).to have_selector('.nav-links li.active', text: 'Pending') expect(page.find('.build-link')).to have_content(build1.id) @@ -59,7 +59,7 @@ describe 'Admin Builds' do it 'shows a message' do create(:ci_build, pipeline: pipeline, status: :success) - visit admin_builds_path(scope: :pending) + visit admin_jobs_path(scope: :pending) expect(page).to have_selector('.nav-links li.active', text: 'Pending') expect(page).to have_content 'No jobs to show' @@ -76,7 +76,7 @@ describe 'Admin Builds' do build3 = create(:ci_build, pipeline: pipeline, status: :failed) build4 = create(:ci_build, pipeline: pipeline, status: :pending) - visit admin_builds_path(scope: :running) + visit admin_jobs_path(scope: :running) expect(page).to have_selector('.nav-links li.active', text: 'Running') expect(page.find('.build-link')).to have_content(build1.id) @@ -91,7 +91,7 @@ describe 'Admin Builds' do it 'shows a message' do create(:ci_build, pipeline: pipeline, status: :success) - visit admin_builds_path(scope: :running) + visit admin_jobs_path(scope: :running) expect(page).to have_selector('.nav-links li.active', text: 'Running') expect(page).to have_content 'No jobs to show' @@ -107,7 +107,7 @@ describe 'Admin Builds' do build2 = create(:ci_build, pipeline: pipeline, status: :running) build3 = create(:ci_build, pipeline: pipeline, status: :success) - visit admin_builds_path(scope: :finished) + visit admin_jobs_path(scope: :finished) expect(page).to have_selector('.nav-links li.active', text: 'Finished') expect(page.find('.build-link')).not_to have_content(build1.id) @@ -121,7 +121,7 @@ describe 'Admin Builds' do it 'shows a message' do create(:ci_build, pipeline: pipeline, status: :running) - visit admin_builds_path(scope: :finished) + visit admin_jobs_path(scope: :finished) expect(page).to have_selector('.nav-links li.active', text: 'Finished') expect(page).to have_content 'No jobs to show' diff --git a/spec/features/admin/admin_conversational_development_index_spec.rb b/spec/features/admin/admin_conversational_development_index_spec.rb new file mode 100644 index 00000000000..739ab907a29 --- /dev/null +++ b/spec/features/admin/admin_conversational_development_index_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe 'Admin Conversational Development Index' do + before do + login_as :admin + end + + context 'when usage ping is disabled' do + it 'shows empty state' do + stub_application_setting(usage_ping_enabled: false) + + visit admin_conversational_development_index_path + + expect(page).to have_content('Usage ping is not enabled') + end + end + + context 'when there is no data to display' do + it 'shows empty state' do + stub_application_setting(usage_ping_enabled: true) + + visit admin_conversational_development_index_path + + expect(page).to have_content('Data is still calculating') + end + end + + context 'when there is data to display' do + it 'shows numbers for each metric' do + stub_application_setting(usage_ping_enabled: true) + create(:conversational_development_index_metric) + + visit admin_conversational_development_index_path + + expect(page).to have_content( + 'Issues created per active user 1.2 You 9.3 Lead 13.3%' + ) + end + end +end diff --git a/spec/features/admin/admin_deploy_keys_spec.rb b/spec/features/admin/admin_deploy_keys_spec.rb index c0b6995a84a..5f5fa4e932a 100644 --- a/spec/features/admin/admin_deploy_keys_spec.rb +++ b/spec/features/admin/admin_deploy_keys_spec.rb @@ -11,40 +11,67 @@ RSpec.describe 'admin deploy keys', type: :feature do it 'show all public deploy keys' do visit admin_deploy_keys_path - expect(page).to have_content(deploy_key.title) - expect(page).to have_content(another_deploy_key.title) + page.within(find('.deploy-keys-list', match: :first)) do + expect(page).to have_content(deploy_key.title) + expect(page).to have_content(another_deploy_key.title) + end end - describe 'create new deploy key' do + describe 'create a new deploy key' do + let(:new_ssh_key) { attributes_for(:key)[:key] } + before do visit admin_deploy_keys_path click_link 'New deploy key' end - it 'creates new deploy key' do - fill_deploy_key + it 'creates a new deploy key' do + fill_in 'deploy_key_title', with: 'laptop' + fill_in 'deploy_key_key', with: new_ssh_key + check 'deploy_key_can_push' click_button 'Create' - expect_renders_new_key - end + expect(current_path).to eq admin_deploy_keys_path - it 'creates new deploy key with write access' do - fill_deploy_key - check "deploy_key_can_push" - click_button "Create" + page.within(find('.deploy-keys-list', match: :first)) do + expect(page).to have_content('laptop') + expect(page).to have_content('Yes') + end + end + end - expect_renders_new_key - expect(page).to have_content('Yes') + describe 'update an existing deploy key' do + before do + visit admin_deploy_keys_path + find('tr', text: deploy_key.title).click_link('Edit') end - def expect_renders_new_key + it 'updates an existing deploy key' do + fill_in 'deploy_key_title', with: 'new-title' + check 'deploy_key_can_push' + click_button 'Save changes' + expect(current_path).to eq admin_deploy_keys_path - expect(page).to have_content('laptop') + + page.within(find('.deploy-keys-list', match: :first)) do + expect(page).to have_content('new-title') + expect(page).to have_content('Yes') + end end + end - def fill_deploy_key - fill_in 'deploy_key_title', with: 'laptop' - fill_in 'deploy_key_key', with: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAzrEJUIR6Y03TCE9rIJ+GqTBvgb8t1jI9h5UBzCLuK4VawOmkLornPqLDrGbm6tcwM/wBrrLvVOqi2HwmkKEIecVO0a64A4rIYScVsXIniHRS6w5twyn1MD3sIbN+socBDcaldECQa2u1dI3tnNVcs8wi77fiRe7RSxePsJceGoheRQgC8AZ510UdIlO+9rjIHUdVN7LLyz512auAfYsgx1OfablkQ/XJcdEwDNgi9imI6nAXhmoKUm1IPLT2yKajTIC64AjLOnE0YyCh6+7RFMpiMyu1qiOCpdjYwTgBRiciNRZCH8xIedyCoAmiUgkUT40XYHwLuwiPJICpkAzp7Q== user@laptop' + describe 'remove an existing deploy key' do + before do + visit admin_deploy_keys_path + end + + it 'removes an existing deploy key' do + find('tr', text: deploy_key.title).click_link('Remove') + + expect(current_path).to eq admin_deploy_keys_path + page.within(find('.deploy-keys-list', match: :first)) do + expect(page).not_to have_content(deploy_key.title) + end end end end 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 273cacd82cd..e8e080ce3e2 100644 --- a/spec/features/admin/admin_disables_git_access_protocol_spec.rb +++ b/spec/features/admin/admin_disables_git_access_protocol_spec.rb @@ -32,7 +32,7 @@ feature 'Admin disables Git access protocol', feature: true do scenario 'shows only HTTP url' do visit_project - expect(page).to have_content("git clone #{project.http_url_to_repo(admin)}") + expect(page).to have_content("git clone #{project.http_url_to_repo}") expect(page).not_to have_selector('#clone-dropdown') end end diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb index d5f595894d6..cf9d7bca255 100644 --- a/spec/features/admin/admin_groups_spec.rb +++ b/spec/features/admin/admin_groups_spec.rb @@ -24,7 +24,9 @@ feature 'Admin Groups', feature: true do it 'creates new group' do visit admin_groups_path - click_link "New group" + page.within '#content-body' do + click_link "New group" + end path_component = 'gitlab' group_name = 'GitLab group name' group_description = 'Description of group for GitLab' diff --git a/spec/features/admin/admin_hook_logs_spec.rb b/spec/features/admin/admin_hook_logs_spec.rb new file mode 100644 index 00000000000..5b67f4de6ac --- /dev/null +++ b/spec/features/admin/admin_hook_logs_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +feature 'Admin::HookLogs', feature: true do + let(:project) { create(:project) } + let(:system_hook) { create(:system_hook) } + let(:hook_log) { create(:web_hook_log, web_hook: system_hook, internal_error_message: 'some error') } + + before do + login_as :admin + end + + scenario 'show list of hook logs' do + hook_log + visit edit_admin_hook_path(system_hook) + + expect(page).to have_content('Recent Deliveries') + expect(page).to have_content(hook_log.url) + end + + scenario 'show hook log details' do + hook_log + visit edit_admin_hook_path(system_hook) + click_link 'View details' + + expect(page).to have_content("POST #{hook_log.url}") + expect(page).to have_content(hook_log.internal_error_message) + expect(page).to have_content('Resend Request') + end + + scenario 'retry hook log' do + WebMock.stub_request(:post, system_hook.url) + + hook_log + visit edit_admin_hook_path(system_hook) + click_link 'View details' + click_link 'Resend Request' + + expect(current_path).to eq(edit_admin_hook_path(system_hook)) + end +end diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index c5f24d412d7..80f7ec43c06 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -58,10 +58,19 @@ describe 'Admin::Hooks', feature: true do end describe 'Remove existing hook' do - it 'remove existing hook' do - visit admin_hooks_path + context 'removes existing hook' do + it 'from hooks list page' do + visit admin_hooks_path + + expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1) + end - expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1) + it 'from hook edit page' do + visit admin_hooks_path + click_link 'Edit' + + expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1) + end end end diff --git a/spec/features/admin/admin_labels_spec.rb b/spec/features/admin/admin_labels_spec.rb index fa3d9ee25c0..a9251db13e5 100644 --- a/spec/features/admin/admin_labels_spec.rb +++ b/spec/features/admin/admin_labels_spec.rb @@ -34,11 +34,11 @@ RSpec.describe 'admin issues labels' do page.within '.labels' do page.all('.btn-remove').each do |remove| remove.click - wait_for_ajax + wait_for_requests end end - wait_for_ajax + wait_for_requests expect(page).to have_content("There are no labels yet") expect(page).not_to have_content('bug') diff --git a/spec/features/admin/admin_system_info_spec.rb b/spec/features/admin/admin_system_info_spec.rb index 1df972843e2..15482347886 100644 --- a/spec/features/admin/admin_system_info_spec.rb +++ b/spec/features/admin/admin_system_info_spec.rb @@ -20,6 +20,7 @@ describe 'Admin System Info' 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' + expect(page).to have_content 'Uptime' end end @@ -34,6 +35,7 @@ describe 'Admin System 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' + expect(page).to have_content 'Uptime' end end @@ -48,6 +50,7 @@ describe 'Admin System 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' + expect(page).to have_content 'Uptime' end end end diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index c5b1ef1295c..301a47169a4 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -21,6 +21,9 @@ describe "Admin::Users", feature: true do expect(page).to have_content(current_user.name) expect(page).to have_content(user.email) expect(page).to have_content(user.name) + expect(page).to have_link('Block', href: block_admin_user_path(user)) + expect(page).to have_link('Remove user', href: admin_user_path(user)) + expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true)) end describe 'Two-factor Authentication filters' do @@ -114,6 +117,9 @@ describe "Admin::Users", feature: true do expect(page).to have_content(user.email) expect(page).to have_content(user.name) + expect(page).to have_link('Block user', href: block_admin_user_path(user)) + expect(page).to have_link('Remove user', href: admin_user_path(user)) + expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true)) end describe 'Impersonation' do @@ -277,7 +283,7 @@ describe "Admin::Users", feature: true do page.within(first('.group_member')) do find('.btn-remove').click end - wait_for_ajax + wait_for_requests expect(page).not_to have_selector('.group_member') end diff --git a/spec/features/admin/admin_uses_repository_checks_spec.rb b/spec/features/admin/admin_uses_repository_checks_spec.rb index 855247de2ea..ab5c42365fe 100644 --- a/spec/features/admin/admin_uses_repository_checks_spec.rb +++ b/spec/features/admin/admin_uses_repository_checks_spec.rb @@ -23,7 +23,7 @@ feature 'Admin uses repository checks', feature: true do project = create(:empty_project) project.update_columns( last_repository_check_failed: true, - last_repository_check_at: Time.now, + last_repository_check_at: Time.now ) visit_admin_project_page(project) diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index 9ea325ab41b..711c8a710f3 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -20,13 +20,20 @@ describe "Dashboard Issues Feed", feature: true do expect(body).to have_selector('title', text: "#{user.name} issues") end + it "renders atom feed via RSS token" do + visit issues_dashboard_path(:atom, rss_token: user.rss_token) + + expect(response_headers['Content-Type']).to have_content('application/atom+xml') + expect(body).to have_selector('title', text: "#{user.name} issues") + end + it "renders atom feed with url parameters" do - visit issues_dashboard_path(:atom, private_token: user.private_token, state: 'opened', assignee_id: user.id) + visit issues_dashboard_path(:atom, rss_token: user.rss_token, state: 'opened', assignee_id: user.id) link = find('link[type="application/atom+xml"]') params = CGI.parse(URI.parse(link[:href]).query) - expect(params).to include('private_token' => [user.private_token]) + expect(params).to include('rss_token' => [user.rss_token]) expect(params).to include('state' => ['opened']) expect(params).to include('assignee_id' => [user.id.to_s]) end @@ -35,7 +42,7 @@ describe "Dashboard Issues Feed", feature: true do let!(:issue2) { create(:issue, author: user, assignees: [assignee], project: project2, description: 'test desc') } it "renders issue fields" do - visit issues_dashboard_path(:atom, private_token: user.private_token) + visit issues_dashboard_path(:atom, rss_token: user.rss_token) entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue2.title}')]") @@ -58,7 +65,7 @@ describe "Dashboard Issues Feed", feature: true do end it "renders issue label and milestone info" do - visit issues_dashboard_path(:atom, private_token: user.private_token) + visit issues_dashboard_path(:atom, rss_token: user.rss_token) entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue1.title}')]") diff --git a/spec/features/atom/dashboard_spec.rb b/spec/features/atom/dashboard_spec.rb index 746df36bb25..1df058b023c 100644 --- a/spec/features/atom/dashboard_spec.rb +++ b/spec/features/atom/dashboard_spec.rb @@ -11,6 +11,13 @@ describe "Dashboard Feed", feature: true do end end + context "projects atom feed via RSS token" do + it "renders projects atom feed" do + visit dashboard_projects_path(:atom, rss_token: user.rss_token) + expect(body).to have_selector('feed title') + end + end + context 'feed content' do let(:project) { create(:project) } let(:issue) { create(:issue, project: project, author: user, description: '') } @@ -20,7 +27,7 @@ describe "Dashboard Feed", feature: true do project.team << [user, :master] issue_event(issue, user) note_event(note, user) - visit dashboard_projects_path(:atom, private_token: user.private_token) + visit dashboard_projects_path(:atom, rss_token: user.rss_token) end it "has issue opened event" do diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb index 4f6754ad541..a61231ea254 100644 --- a/spec/features/atom/issues_spec.rb +++ b/spec/features/atom/issues_spec.rb @@ -43,25 +43,40 @@ describe 'Issues Feed', feature: true do end end + context 'when authenticated via RSS token' do + it 'renders atom feed' do + visit namespace_project_issues_path(project.namespace, project, :atom, + rss_token: user.rss_token) + + expect(response_headers['Content-Type']). + to have_content('application/atom+xml') + expect(body).to have_selector('title', text: "#{project.name} issues") + expect(body).to have_selector('author email', text: issue.author_public_email) + expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) + expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) + expect(body).to have_selector('entry summary', text: issue.title) + end + end + it "renders atom feed with url parameters for project issues" do visit namespace_project_issues_path(project.namespace, project, - :atom, private_token: user.private_token, state: 'opened', assignee_id: user.id) + :atom, rss_token: user.rss_token, state: 'opened', assignee_id: user.id) link = find('link[type="application/atom+xml"]') params = CGI.parse(URI.parse(link[:href]).query) - expect(params).to include('private_token' => [user.private_token]) + expect(params).to include('rss_token' => [user.rss_token]) expect(params).to include('state' => ['opened']) expect(params).to include('assignee_id' => [user.id.to_s]) end it "renders atom feed with url parameters for group issues" do - visit issues_group_path(group, :atom, private_token: user.private_token, state: 'opened', assignee_id: user.id) + visit issues_group_path(group, :atom, rss_token: user.rss_token, state: 'opened', assignee_id: user.id) link = find('link[type="application/atom+xml"]') params = CGI.parse(URI.parse(link[:href]).query) - expect(params).to include('private_token' => [user.private_token]) + expect(params).to include('rss_token' => [user.rss_token]) expect(params).to include('state' => ['opened']) expect(params).to include('assignee_id' => [user.id.to_s]) end diff --git a/spec/features/atom/users_spec.rb b/spec/features/atom/users_spec.rb index 7a2987e815d..fae5aaa52bd 100644 --- a/spec/features/atom/users_spec.rb +++ b/spec/features/atom/users_spec.rb @@ -11,6 +11,13 @@ describe "User Feed", feature: true do end end + context 'user atom feed via RSS token' do + it "renders user atom feed" do + visit user_path(user, :atom, rss_token: user.rss_token) + expect(body).to have_selector('feed title') + end + end + context 'feed content' do let(:project) { create(:project) } let(:issue) do @@ -40,7 +47,7 @@ describe "User Feed", feature: true do issue_event(issue, user) note_event(note, user) merge_request_event(merge_request, user) - visit user_path(user, :atom, private_token: user.private_token) + visit user_path(user, :atom, rss_token: user.rss_token) end it 'has issue opened event' do diff --git a/spec/features/auto_deploy_spec.rb b/spec/features/auto_deploy_spec.rb index 67b0f006854..1cf7396bbac 100644 --- a/spec/features/auto_deploy_spec.rb +++ b/spec/features/auto_deploy_spec.rb @@ -5,14 +5,7 @@ describe 'Auto deploy' do let(:project) { create(:project, :repository) } before do - project.create_kubernetes_service( - active: true, - properties: { - namespace: project.path, - api_url: 'https://kubernetes.example.com', - token: 'a' * 40, - } - ) + create :kubernetes_service, project: project project.team << [user, :master] login_as user end @@ -53,7 +46,7 @@ describe 'Auto deploy' do within '.gitlab-ci-yml-selector' do click_on 'OpenShift' end - wait_for_ajax + wait_for_requests click_button 'Commit changes' expect(page).to have_content('New Merge Request From auto-deploy into master') diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb index 505e0b5c355..2b8edac4f10 100644 --- a/spec/features/boards/add_issues_modal_spec.rb +++ b/spec/features/boards/add_issues_modal_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' describe 'Issue Boards add issue modal', :feature, :js do - include WaitForVueResource - let(:project) { create(:empty_project, :public) } let(:board) { create(:board, project: project) } let(:user) { create(:user) } @@ -19,13 +17,13 @@ describe 'Issue Boards add issue modal', :feature, :js do login_as(user) visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests end it 'resets filtered search state' do visit namespace_project_board_path(project.namespace, project, board, search: 'testing') - wait_for_vue_resource + wait_for_requests click_button('Add issues') @@ -74,7 +72,7 @@ describe 'Issue Boards add issue modal', :feature, :js do before do click_button('Add issues') - wait_for_vue_resource + wait_for_requests end it 'loads issues' do @@ -107,7 +105,7 @@ describe 'Issue Boards add issue modal', :feature, :js do click_button('Add issues') - wait_for_vue_resource + wait_for_requests page.within('.add-issues-modal') do expect(find('.add-issues-footer')).not_to have_button(planning.title) @@ -122,7 +120,7 @@ describe 'Issue Boards add issue modal', :feature, :js do find('.form-control').native.send_keys(issue.title) find('.form-control').native.send_keys(:enter) - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.card', count: 1) end @@ -133,7 +131,7 @@ describe 'Issue Boards add issue modal', :feature, :js do find('.form-control').native.send_keys('testing search') find('.form-control').native.send_keys(:enter) - wait_for_vue_resource + wait_for_requests expect(page).not_to have_selector('.card') expect(page).not_to have_content("You haven't added any issues to your project yet") @@ -233,7 +231,7 @@ describe 'Issue Boards add issue modal', :feature, :js do click_button 'Add 1 issue' end - page.within(first('.board')) do + page.within(find('.board:nth-child(2)')) do expect(page).to have_selector('.card') end end @@ -249,7 +247,7 @@ describe 'Issue Boards add issue modal', :feature, :js do click_button 'Add 1 issue' end - page.within(find('.board:nth-child(2)')) do + page.within(find('.board:nth-child(3)')) do expect(page).to have_selector('.card') end end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 18585488e26..c80453b8227 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' describe 'Issue Boards', feature: true, js: true do - include WaitForVueResource include DragTo let(:project) { create(:empty_project, :public) } @@ -19,8 +18,8 @@ describe 'Issue Boards', feature: true, js: true do 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: 2) + wait_for_requests + expect(page).to have_selector('.board', count: 3) end it 'shows blank state' do @@ -37,18 +36,18 @@ describe 'Issue Boards', feature: true, js: true do page.within(find('.board-blank-state')) do click_button("Nevermind, I'll use my own") end - expect(page).to have_selector('.board', count: 1) + expect(page).to have_selector('.board', count: 2) end it 'creates default lists' do - lists = ['To Do', 'Doing', 'Closed'] + lists = ['Backlog', 'To Do', 'Doing', 'Closed'] page.within(find('.board-blank-state')) do click_button('Add default lists') end - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 3) + 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]) @@ -84,31 +83,27 @@ describe 'Issue Boards', feature: true, js: true do before do visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 3) - expect(find('.board:nth-child(1)')).to have_selector('.card') + expect(page).to have_selector('.board', count: 4) expect(find('.board:nth-child(2)')).to have_selector('.card') expect(find('.board:nth-child(3)')).to have_selector('.card') - end - - it 'shows lists' do - expect(page).to have_selector('.board', count: 3) + expect(find('.board:nth-child(4)')).to have_selector('.card') end it 'shows description tooltip on list title' do - page.within('.board:nth-child(1)') do + page.within('.board:nth-child(2)') do expect(find('.board-title span.has-tooltip')[:title]).to eq('Test') end end it 'shows issues in lists' do - wait_for_board_cards(1, 8) - wait_for_board_cards(2, 2) + wait_for_board_cards(2, 8) + wait_for_board_cards(3, 2) end it 'shows confidential issues with icon' do - page.within(find('.board', match: :first)) do + page.within(find('.board:nth-child(2)')) do expect(page).to have_selector('.confidential-icon', count: 1) end end @@ -117,45 +112,45 @@ describe 'Issue Boards', feature: true, js: true do find('.filtered-search').set(issue8.title) find('.filtered-search').native.send_keys(:enter) - wait_for_vue_resource + wait_for_requests - 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: 1) + 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 find('.filtered-search').set(issue5.title) find('.filtered-search').native.send_keys(:enter) - wait_for_vue_resource + wait_for_requests - 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(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(1)')) do + page.within(find('.board:nth-child(2)')) do find('.board-delete').click end - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 2) + expect(page).to have_selector('.board', count: 3) end it 'removes checkmark in new list dropdown after deleting' do click_button 'Add list' - wait_for_ajax + wait_for_requests - page.within(find('.board:nth-child(1)')) do + page.within(find('.board:nth-child(2)')) do find('.board-delete').click end - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 2) + expect(page).to have_selector('.board', count: 3) end it 'infinite scrolls list' do @@ -164,21 +159,21 @@ describe 'Issue Boards', feature: true, js: true do end visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests - page.within(find('.board', match: :first)) do + page.within(find('.board:nth-child(2)')) do expect(page.find('.board-header')).to have_content('58') expect(page).to have_selector('.card', count: 20) expect(page).to have_content('Showing 20 of 58 issues') - evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight") - wait_for_vue_resource + evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") + wait_for_requests expect(page).to have_selector('.card', count: 40) expect(page).to have_content('Showing 40 of 58 issues') - evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight") - wait_for_vue_resource + evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") + wait_for_requests expect(page).to have_selector('.card', count: 58) expect(page).to have_content('Showing all issues') @@ -187,83 +182,83 @@ describe 'Issue Boards', feature: true, js: true do context 'closed' do it 'shows list of closed issues' do - wait_for_board_cards(3, 1) - wait_for_ajax + wait_for_board_cards(4, 1) + wait_for_requests end it 'moves issue to closed' do - drag(list_from_index: 0, list_to_index: 2) + drag(list_from_index: 1, list_to_index: 3) - wait_for_board_cards(1, 7) - wait_for_board_cards(2, 2) + wait_for_board_cards(2, 7) 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(3)')).to have_selector('.card', count: 2) - expect(find('.board:nth-child(3)')).to have_content(issue9.title) - expect(find('.board:nth-child(3)')).not_to have_content(planning.title) + expect(find('.board:nth-child(2)')).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 closed' do - drag(list_from_index: 0, list_to_index: 2) + drag(list_from_index: 1, list_to_index: 3) - wait_for_board_cards(1, 7) - wait_for_board_cards(2, 2) + wait_for_board_cards(2, 7) 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(3)')).to have_content(issue9.title) - expect(find('.board:nth-child(3)')).not_to have_content(planning.title) + expect(find('.board:nth-child(2)')).not_to have_content(issue9.title) + expect(find('.board:nth-child(4)')).to have_content(issue9.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(list_from_index: 1, list_to_index: 0, selector: '.board-header') + drag(list_from_index: 2, list_to_index: 1, selector: '.board-header') - wait_for_board_cards(1, 2) - wait_for_board_cards(2, 8) - wait_for_board_cards(3, 1) + wait_for_board_cards(2, 2) + wait_for_board_cards(3, 8) + wait_for_board_cards(4, 1) - expect(find('.board:nth-child(1)')).to have_content(development.title) - expect(find('.board:nth-child(1)')).to have_content(planning.title) + 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(list_from_index: 0, from_index: 1, list_to_index: 1) + drag(list_from_index: 1, from_index: 1, list_to_index: 2) - wait_for_board_cards(1, 7) - wait_for_board_cards(2, 2) - wait_for_board_cards(3, 1) + wait_for_board_cards(2, 7) + wait_for_board_cards(3, 2) + wait_for_board_cards(4, 1) - expect(find('.board:nth-child(2)')).to have_content(issue6.title) - expect(find('.board:nth-child(2)').all('.card').last).not_to have_content(development.title) + 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(list_from_index: 1, list_to_index: 0) + drag(list_from_index: 2, list_to_index: 1) - wait_for_board_cards(1, 9) - wait_for_board_cards(2, 1) + wait_for_board_cards(2, 9) wait_for_board_cards(3, 1) + wait_for_board_cards(4, 1) - expect(find('.board:nth-child(1)')).to have_content(issue7.title) - expect(find('.board:nth-child(1)').all('.card').first).not_to have_content(planning.title) + 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 closed' do - drag(list_from_index: 2, list_to_index: 1) + drag(list_from_index: 3, list_to_index: 2) - expect(find('.board:nth-child(2)')).to have_content(issue8.title) + expect(find('.board:nth-child(3)')).to have_content(issue8.title) - wait_for_board_cards(1, 8) - wait_for_board_cards(2, 3) - wait_for_board_cards(3, 0) + wait_for_board_cards(2, 8) + wait_for_board_cards(3, 3) + wait_for_board_cards(4, 0) end context 'issue card' do it 'shows assignee' do - page.within(find('.board', match: :first)) do + page.within(find('.board:nth-child(2)')) do expect(page).to have_selector('.avatar', count: 1) end end @@ -272,7 +267,7 @@ describe 'Issue Boards', feature: true, js: true do context 'new list' do it 'shows all labels in new list dropdown' do click_button 'Add list' - wait_for_ajax + wait_for_requests page.within('.dropdown-menu-issues-board-new') do expect(page).to have_content(planning.title) @@ -283,52 +278,52 @@ describe 'Issue Boards', feature: true, js: true do it 'creates new list for label' do click_button 'Add list' - wait_for_ajax + wait_for_requests page.within('.dropdown-menu-issues-board-new') do click_link testing.title end - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 4) + expect(page).to have_selector('.board', count: 5) end it 'creates new list for Backlog label' do click_button 'Add list' - wait_for_ajax + wait_for_requests page.within('.dropdown-menu-issues-board-new') do click_link backlog.title end - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 4) + expect(page).to have_selector('.board', count: 5) end it 'creates new list for Closed label' do click_button 'Add list' - wait_for_ajax + wait_for_requests page.within('.dropdown-menu-issues-board-new') do click_link closed.title end - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 4) + expect(page).to have_selector('.board', count: 5) end it 'keeps dropdown open after adding new list' do click_button 'Add list' - wait_for_ajax + wait_for_requests page.within('.dropdown-menu-issues-board-new') do click_link closed.title end - wait_for_vue_resource + wait_for_requests expect(page).to have_css('#js-add-list.open') end @@ -336,7 +331,7 @@ describe 'Issue Boards', feature: true, js: true do it 'creates new list from a new label' do click_button 'Add list' - wait_for_ajax + wait_for_requests click_link 'Create new label' @@ -346,10 +341,10 @@ describe 'Issue Boards', feature: true, js: true do click_button 'Create' - wait_for_ajax - wait_for_vue_resource + wait_for_requests + wait_for_requests - expect(page).to have_selector('.board', count: 4) + expect(page).to have_selector('.board', count: 5) end end end @@ -360,9 +355,9 @@ describe 'Issue Boards', feature: true, js: true do click_filter_link(user2.username) submit_filter - wait_for_vue_resource - wait_for_board_cards(1, 1) - wait_for_empty_boards((2..3)) + wait_for_requests + wait_for_board_cards(2, 1) + wait_for_empty_boards((3..4)) end it 'filters by assignee' do @@ -370,10 +365,10 @@ describe 'Issue Boards', feature: true, js: true do click_filter_link(user.username) submit_filter - wait_for_vue_resource + wait_for_requests - wait_for_board_cards(1, 1) - wait_for_empty_boards((2..3)) + wait_for_board_cards(2, 1) + wait_for_empty_boards((3..4)) end it 'filters by milestone' do @@ -381,10 +376,10 @@ describe 'Issue Boards', feature: true, js: true do click_filter_link(milestone.title) submit_filter - wait_for_vue_resource - wait_for_board_cards(1, 1) - wait_for_board_cards(2, 0) + wait_for_requests + wait_for_board_cards(2, 1) wait_for_board_cards(3, 0) + wait_for_board_cards(4, 0) end it 'filters by label' do @@ -392,9 +387,9 @@ describe 'Issue Boards', feature: true, js: true do click_filter_link(testing.title) submit_filter - wait_for_vue_resource - wait_for_board_cards(1, 1) - wait_for_empty_boards((2..3)) + wait_for_requests + wait_for_board_cards(2, 1) + wait_for_empty_boards((3..4)) end it 'filters by label with space after reload' do @@ -404,17 +399,17 @@ describe 'Issue Boards', feature: true, js: true do # Test after reload page.evaluate_script 'window.location.reload()' - wait_for_board_cards(1, 1) - wait_for_empty_boards((2..3)) + wait_for_board_cards(2, 1) + wait_for_empty_boards((3..4)) - wait_for_vue_resource + wait_for_requests - page.within(find('.board', match: :first)) do + page.within(find('.board:nth-child(2)')) 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 + page.within(find('.board:nth-child(3)')) do expect(page.find('.board-header')).to have_content('0') expect(page).to have_selector('.card', count: 0) end @@ -425,12 +420,12 @@ describe 'Issue Boards', feature: true, js: true do click_filter_link(testing.title) submit_filter - wait_for_board_cards(1, 1) + wait_for_board_cards(2, 1) find('.clear-search').click submit_filter - wait_for_board_cards(1, 8) + wait_for_board_cards(2, 8) end it 'infinite scrolls list with label filter' do @@ -442,19 +437,19 @@ describe 'Issue Boards', feature: true, js: true do click_filter_link(testing.title) submit_filter - wait_for_vue_resource + wait_for_requests - page.within(find('.board', match: :first)) do + page.within(find('.board:nth-child(2)')) 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") + evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].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") + evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") expect(page).to have_selector('.card', count: 51) expect(page).to have_content('Showing all issues') @@ -470,42 +465,42 @@ describe 'Issue Boards', feature: true, js: true do submit_filter - wait_for_vue_resource + wait_for_requests - wait_for_board_cards(1, 1) - wait_for_empty_boards((2..3)) + wait_for_board_cards(2, 1) + wait_for_empty_boards((3..4)) end it 'filters by clicking label button on issue' do - page.within(find('.board', match: :first)) do + page.within(find('.board:nth-child(2)')) do expect(page).to have_selector('.card', count: 8) expect(find('.card', match: :first)).to have_content(bug.title) click_button(bug.title) - wait_for_vue_resource + wait_for_requests end page.within('.tokens-container') do expect(page).to have_content(bug.title) end - wait_for_vue_resource + wait_for_requests - wait_for_board_cards(1, 1) - wait_for_empty_boards((2..3)) + wait_for_board_cards(2, 1) + wait_for_empty_boards((3..4)) end it 'removes label filter by clicking label button on issue' do - page.within(find('.board', match: :first)) do + page.within(find('.board:nth-child(2)')) do page.within(find('.card', match: :first)) do click_button(bug.title) end - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.card', count: 1) end - wait_for_vue_resource + wait_for_requests end end end @@ -513,7 +508,7 @@ describe 'Issue Boards', feature: true, js: true do context 'keyboard shortcuts' do before do visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests end it 'allows user to use keyboard shortcuts' do @@ -526,7 +521,7 @@ describe 'Issue Boards', feature: true, js: true do before do logout visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests end it 'displays lists' do @@ -550,7 +545,7 @@ describe 'Issue Boards', feature: true, js: true do logout login_as(user_guest) visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests end it 'does not show create new list' do diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb index bfa2a72a256..76239c49ea6 100644 --- a/spec/features/boards/issue_ordering_spec.rb +++ b/spec/features/boards/issue_ordering_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' describe 'Issue Boards', :feature, :js do - include WaitForVueResource include DragTo let(:project) { create(:empty_project, :public) } @@ -24,13 +23,13 @@ describe 'Issue Boards', :feature, :js do before do visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 2) + expect(page).to have_selector('.board', count: 3) end it 'has un-ordered issue as last issue' do - page.within(first('.board')) do + page.within(find('.board:nth-child(2)')) do expect(all('.card').last).to have_content(issue4.title) end end @@ -38,9 +37,15 @@ describe 'Issue Boards', :feature, :js do it 'moves un-ordered issue to top of list' do drag(from_index: 3, to_index: 0) +<<<<<<< HEAD wait_for_vue_resource page.within(first('.board')) do +======= + wait_for_requests + + page.within(find('.board:nth-child(2)')) do +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(first('.card')).to have_content(issue4.title) end end @@ -49,15 +54,15 @@ describe 'Issue Boards', :feature, :js do context 'ordering in list' do before do visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 2) + expect(page).to have_selector('.board', count: 3) end it 'moves from middle to top' do drag(from_index: 1, to_index: 0) - wait_for_vue_resource + wait_for_requests expect(first('.card')).to have_content(issue2.title) end @@ -65,7 +70,7 @@ describe 'Issue Boards', :feature, :js do it 'moves from middle to bottom' do drag(from_index: 1, to_index: 2) - wait_for_vue_resource + wait_for_requests expect(all('.card').last).to have_content(issue2.title) end @@ -73,7 +78,7 @@ describe 'Issue Boards', :feature, :js do it 'moves from top to bottom' do drag(from_index: 0, to_index: 2) - wait_for_vue_resource + wait_for_requests expect(all('.card').last).to have_content(issue3.title) end @@ -81,7 +86,7 @@ describe 'Issue Boards', :feature, :js do it 'moves from bottom to top' do drag(from_index: 2, to_index: 0) - wait_for_vue_resource + wait_for_requests expect(first('.card')).to have_content(issue1.title) end @@ -89,7 +94,7 @@ describe 'Issue Boards', :feature, :js do it 'moves from top to middle' do drag(from_index: 0, to_index: 1) - wait_for_vue_resource + wait_for_requests expect(first('.card')).to have_content(issue2.title) end @@ -97,7 +102,7 @@ describe 'Issue Boards', :feature, :js do it 'moves from bottom to middle' do drag(from_index: 2, to_index: 1) - wait_for_vue_resource + wait_for_requests expect(all('.card').last).to have_content(issue2.title) end @@ -112,52 +117,52 @@ describe 'Issue Boards', :feature, :js do before do visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 3) + expect(page).to have_selector('.board', count: 4) end it 'moves to top of another list' do - drag(list_from_index: 0, list_to_index: 1) + drag(list_from_index: 1, list_to_index: 2) - wait_for_vue_resource + wait_for_requests - expect(first('.board')).to have_selector('.card', count: 2) - expect(all('.board')[1]).to have_selector('.card', count: 4) + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 2) + expect(all('.board')[2]).to have_selector('.card', count: 4) - page.within(all('.board')[1]) do + page.within(all('.board')[2]) do expect(first('.card')).to have_content(issue3.title) end end it 'moves to bottom of another list' do - drag(list_from_index: 0, list_to_index: 1, to_index: 2) + drag(list_from_index: 1, list_to_index: 2, to_index: 2) - wait_for_vue_resource + wait_for_requests - expect(first('.board')).to have_selector('.card', count: 2) - expect(all('.board')[1]).to have_selector('.card', count: 4) + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 2) + expect(all('.board')[2]).to have_selector('.card', count: 4) - page.within(all('.board')[1]) do + page.within(all('.board')[2]) do expect(all('.card').last).to have_content(issue3.title) end end it 'moves to index of another list' do - drag(list_from_index: 0, list_to_index: 1, to_index: 1) + drag(list_from_index: 1, list_to_index: 2, to_index: 1) - wait_for_vue_resource + wait_for_requests - expect(first('.board')).to have_selector('.card', count: 2) - expect(all('.board')[1]).to have_selector('.card', count: 4) + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 2) + expect(all('.board')[2]).to have_selector('.card', count: 4) - page.within(all('.board')[1]) do + page.within(all('.board')[2]) do expect(all('.card')[1]).to have_content(issue3.title) end end end - def drag(selector: '.board-list', list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0) + def drag(selector: '.board-list', list_from_index: 1, from_index: 0, to_index: 0, list_to_index: 1) drag_to(selector: selector, scrollable: '#board-app', list_from_index: list_from_index, diff --git a/spec/features/boards/keyboard_shortcut_spec.rb b/spec/features/boards/keyboard_shortcut_spec.rb index a9cc6c49f8e..c2167ba12cd 100644 --- a/spec/features/boards/keyboard_shortcut_spec.rb +++ b/spec/features/boards/keyboard_shortcut_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' describe 'Issue Boards shortcut', feature: true, js: true do - include WaitForVueResource - let(:project) { create(:empty_project) } before do @@ -17,6 +15,6 @@ describe 'Issue Boards shortcut', feature: true, js: true do find('body').native.send_keys('gb') expect(page).to have_selector('.boards-list') - wait_for_vue_resource + wait_for_requests end end diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index e1367c675e5..b6de6143354 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' describe 'Issue Boards add issue modal filtering', :feature, :js do - include WaitForVueResource - let(:project) { create(:empty_project, :public) } let(:board) { create(:board, project: project) } let(:planning) { create(:label, project: project, name: 'Planning') } @@ -24,7 +22,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do find('.form-control').native.send_keys('testing empty state') find('.form-control').native.send_keys(:enter) - wait_for_vue_resource + wait_for_requests expect(page).to have_content('There are no issues to show.') end @@ -38,7 +36,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do submit_filter page.within('.add-issues-modal') do - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.card', count: 0) @@ -48,7 +46,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do click_button('Add issues') page.within('.add-issues-modal') do - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.card', count: 1) end @@ -62,13 +60,13 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do submit_filter page.within('.add-issues-modal') do - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.card', count: 0) find('.clear-search').click - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.card', count: 1) end @@ -89,9 +87,9 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do submit_filter page.within('.add-issues-modal') do - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.js-visual-token', text: user2.username) + expect(page).to have_selector('.js-visual-token', text: user2.name) expect(page).to have_selector('.card', count: 1) end end @@ -112,7 +110,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do submit_filter page.within('.add-issues-modal') do - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.js-visual-token', text: 'none') expect(page).to have_selector('.card', count: 1) @@ -125,9 +123,9 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do submit_filter page.within('.add-issues-modal') do - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.js-visual-token', text: user2.username) + expect(page).to have_selector('.js-visual-token', text: user2.name) expect(page).to have_selector('.card', count: 1) end end @@ -147,7 +145,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do submit_filter page.within('.add-issues-modal') do - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.js-visual-token', text: 'upcoming') expect(page).to have_selector('.card', count: 0) @@ -160,7 +158,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do submit_filter page.within('.add-issues-modal') do - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.js-visual-token', text: milestone.name) expect(page).to have_selector('.card', count: 1) @@ -182,7 +180,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do submit_filter page.within('.add-issues-modal') do - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.js-visual-token', text: 'none') expect(page).to have_selector('.card', count: 1) @@ -195,7 +193,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do submit_filter page.within('.add-issues-modal') do - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.js-visual-token', text: label.title) expect(page).to have_selector('.card', count: 1) @@ -205,7 +203,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do def visit_board visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests click_button('Add issues') end diff --git a/spec/features/boards/new_issue_spec.rb b/spec/features/boards/new_issue_spec.rb index f04a1a89e96..056224dc436 100644 --- a/spec/features/boards/new_issue_spec.rb +++ b/spec/features/boards/new_issue_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' describe 'Issue Boards new issue', feature: true, js: true do - include WaitForVueResource - let(:project) { create(:empty_project, :public) } let(:board) { create(:board, project: project) } let!(:list) { create(:list, board: board, position: 0) } @@ -15,17 +13,17 @@ describe 'Issue Boards new issue', feature: true, js: true do login_as(user) visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests - expect(page).to have_selector('.board', count: 2) + 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) + expect(first('.board')).to have_selector('.board-issue-count-holder .btn', count: 1) end it 'does not display new issue button in closed list' do - page.within('.board:nth-child(2)') do + page.within('.board:nth-child(3)') do expect(page).not_to have_selector('.board-issue-count-holder .btn') end end @@ -60,7 +58,7 @@ describe 'Issue Boards new issue', feature: true, js: true do click_button 'Submit issue' end - wait_for_vue_resource + wait_for_requests page.within(first('.board .board-issue-count')) do expect(page).to have_content('1') @@ -77,7 +75,7 @@ describe 'Issue Boards new issue', feature: true, js: true do click_button 'Submit issue' end - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.issue-boards-sidebar') end @@ -86,7 +84,7 @@ describe 'Issue Boards new issue', feature: true, js: true do context 'unauthorized user' do before do visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests end it 'does not display new issue button' do diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index a5ef280a60f..e066b2c178f 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' describe 'Issue Boards', feature: true, js: true do - include WaitForVueResource - let(:user) { create(:user) } let(:user2) { create(:user) } let(:project) { create(:empty_project, :public) } @@ -15,7 +13,7 @@ describe 'Issue Boards', feature: true, js: true do let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) } let(:board) { create(:board, project: project) } let!(:list) { create(:list, board: board, label: development, position: 0) } - let(:card) { first('.board').first('.card') } + let(:card) { find('.board:nth-child(2)').first('.card') } before do Timecop.freeze @@ -25,7 +23,7 @@ describe 'Issue Boards', feature: true, js: true do login_as(user) visit namespace_project_board_path(project.namespace, project, board) - wait_for_vue_resource + wait_for_requests end after do @@ -74,9 +72,9 @@ describe 'Issue Boards', feature: true, js: true do click_button 'Remove from board' end - wait_for_vue_resource + wait_for_requests - page.within(first('.board')) do + page.within(find('.board:nth-child(2)')) do expect(page).to have_selector('.card', count: 1) end end @@ -88,12 +86,12 @@ describe 'Issue Boards', feature: true, js: true do page.within('.assignee') do click_link 'Edit' - wait_for_ajax + wait_for_requests page.within('.dropdown-menu-user') do click_link user.name - wait_for_vue_resource + wait_for_requests end expect(page).to have_content(user.name) @@ -103,19 +101,23 @@ describe 'Issue Boards', feature: true, js: true do end it 'removes the assignee' do - card_two = first('.board').find('.card:nth-child(2)') + card_two = find('.board:nth-child(2)').find('.card:nth-child(2)') click_card(card_two) page.within('.assignee') do click_link 'Edit' - wait_for_ajax + wait_for_requests page.within('.dropdown-menu-user') do click_link 'Unassigned' end +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_content('No assignee') end @@ -131,7 +133,7 @@ describe 'Issue Boards', feature: true, js: true do click_button 'assign yourself' - wait_for_vue_resource + wait_for_requests expect(page).to have_content(user.name) end @@ -145,25 +147,25 @@ describe 'Issue Boards', feature: true, js: true do page.within('.assignee') do click_link 'Edit' - wait_for_ajax + wait_for_requests page.within('.dropdown-menu-user') do click_link user.name - wait_for_vue_resource + wait_for_requests end expect(page).to have_content(user.name) end - page.within(first('.board')) do - find('.card:nth-child(2)').click + page.within(find('.board:nth-child(2)')) do + find('.card:nth-child(2)').trigger('click') end page.within('.assignee') do click_link 'Edit' - expect(page).to have_selector('.is-active') + expect(find('.dropdown-menu')).to have_selector('.is-active') end end end @@ -175,11 +177,11 @@ describe 'Issue Boards', feature: true, js: true do page.within('.milestone') do click_link 'Edit' - wait_for_ajax + wait_for_requests click_link milestone.title - wait_for_vue_resource + wait_for_requests page.within('.value') do expect(page).to have_content(milestone.title) @@ -193,11 +195,11 @@ describe 'Issue Boards', feature: true, js: true do page.within('.milestone') do click_link 'Edit' - wait_for_ajax + wait_for_requests click_link "No Milestone" - wait_for_vue_resource + wait_for_requests page.within('.value') do expect(page).not_to have_content(milestone.title) @@ -215,7 +217,7 @@ describe 'Issue Boards', feature: true, js: true do click_button Date.today.day - wait_for_vue_resource + wait_for_requests expect(page).to have_content(Date.today.to_s(:medium)) end @@ -229,11 +231,11 @@ describe 'Issue Boards', feature: true, js: true do page.within('.labels') do click_link 'Edit' - wait_for_ajax + wait_for_requests click_link bug.title - wait_for_vue_resource + wait_for_requests find('.dropdown-menu-close-icon').click @@ -253,12 +255,12 @@ describe 'Issue Boards', feature: true, js: true do page.within('.labels') do click_link 'Edit' - wait_for_ajax + wait_for_requests click_link bug.title click_link regression.title - wait_for_vue_resource + wait_for_requests find('.dropdown-menu-close-icon').click @@ -280,11 +282,11 @@ describe 'Issue Boards', feature: true, js: true do page.within('.labels') do click_link 'Edit' - wait_for_ajax + wait_for_requests click_link stretch.title - wait_for_vue_resource + wait_for_requests find('.dropdown-menu-close-icon').click @@ -305,7 +307,7 @@ describe 'Issue Boards', feature: true, js: true do page.within('.subscription') do click_button 'Subscribe' - wait_for_ajax + wait_for_requests expect(page).to have_content("Unsubscribe") end end diff --git a/spec/features/boards/sub_group_project_spec.rb b/spec/features/boards/sub_group_project_spec.rb index 6cd7fddd288..481973da86e 100644 --- a/spec/features/boards/sub_group_project_spec.rb +++ b/spec/features/boards/sub_group_project_spec.rb @@ -1,8 +1,11 @@ require 'rails_helper' describe 'Sub-group project issue boards', :feature, :js do +<<<<<<< HEAD include WaitForVueResource +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 let(:group) { create(:group) } let(:nested_group_1) { create(:group, parent: group) } let(:project) { create(:empty_project, group: nested_group_1) } @@ -18,7 +21,11 @@ describe 'Sub-group project issue boards', :feature, :js do login_as(user) visit namespace_project_board_path(project.namespace, project, board) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end it 'creates new label from sidebar' do @@ -35,7 +42,11 @@ describe 'Sub-group project issue boards', :feature, :js do click_button 'Create' +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end page.within '.labels' do diff --git a/spec/features/calendar_spec.rb b/spec/features/calendar_spec.rb index 496faf87a16..1b6d8439f92 100644 --- a/spec/features/calendar_spec.rb +++ b/spec/features/calendar_spec.rb @@ -74,7 +74,7 @@ feature 'Contributions Calendar', :feature, :js do describe 'calendar day selection' do before do visit user.username - wait_for_ajax + wait_for_requests end it 'displays calendar' do @@ -86,7 +86,7 @@ feature 'Contributions Calendar', :feature, :js do before do cells[0].click - wait_for_ajax + wait_for_requests @first_day_activities = selected_day_activities end @@ -97,7 +97,7 @@ feature 'Contributions Calendar', :feature, :js do describe 'select another calendar day' do before do cells[1].click - wait_for_ajax + wait_for_requests end it 'displays different calendar day activities' do @@ -108,7 +108,7 @@ feature 'Contributions Calendar', :feature, :js do describe 'deselect calendar day' do before do cells[0].click - wait_for_ajax + wait_for_requests end it 'hides calendar day activities' do @@ -122,7 +122,7 @@ feature 'Contributions Calendar', :feature, :js do shared_context 'visit user page' do before do visit user.username - wait_for_ajax + wait_for_requests end end diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index e6c4ab24de5..2772f05982a 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -76,7 +76,7 @@ describe 'Commits' do end end - describe 'Commit builds' do + describe 'Commit builds', :feature, :js do before do visit ci_status_path(pipeline) end @@ -85,7 +85,6 @@ describe 'Commits' do expect(page).to have_content pipeline.sha[0..7] expect(page).to have_content pipeline.git_commit_message expect(page).to have_content pipeline.user.name - expect(page).to have_content pipeline.created_at.strftime('%b %d, %Y') end end @@ -102,7 +101,7 @@ describe 'Commits' do end describe 'Cancel all builds' do - it 'cancels commit' do + it 'cancels commit', :js do visit ci_status_path(pipeline) click_on 'Cancel running' expect(page).to have_content 'canceled' @@ -110,9 +109,9 @@ describe 'Commits' do end describe 'Cancel build' do - it 'cancels build' do + it 'cancels build', :js do visit ci_status_path(pipeline) - find('a.btn[title="Cancel"]').click + find('.js-btn-cancel-pipeline').click expect(page).to have_content 'canceled' end end @@ -152,17 +151,20 @@ describe 'Commits' do visit ci_status_path(pipeline) end - it do + it 'Renders header', :feature, :js do expect(page).to have_content pipeline.sha[0..7] expect(page).to have_content pipeline.git_commit_message expect(page).to have_content pipeline.user.name - expect(page).to have_link('Download artifacts') expect(page).not_to have_link('Cancel running') expect(page).not_to have_link('Retry') end + + it do + expect(page).to have_link('Download artifacts') + end end - context 'when accessing internal project with disallowed access' do + context 'when accessing internal project with disallowed access', :feature, :js do before do project.update( visibility_level: Gitlab::VisibilityLevel::INTERNAL, @@ -175,7 +177,7 @@ describe 'Commits' do expect(page).to have_content pipeline.sha[0..7] expect(page).to have_content pipeline.git_commit_message expect(page).to have_content pipeline.user.name - expect(page).not_to have_link('Download artifacts') + expect(page).not_to have_link('Cancel running') expect(page).not_to have_link('Retry') end diff --git a/spec/features/container_registry_spec.rb b/spec/features/container_registry_spec.rb index b86609e07c5..fa7adbe71ea 100644 --- a/spec/features/container_registry_spec.rb +++ b/spec/features/container_registry_spec.rb @@ -19,7 +19,7 @@ describe "Container Registry" do scenario 'user visits container registry main page' do visit_container_registry - expect(page).to have_content 'No container image repositories' + expect(page).to have_content 'No container images' end end diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb index f197fb44608..740f60c05cc 100644 --- a/spec/features/copy_as_gfm_spec.rb +++ b/spec/features/copy_as_gfm_spec.rb @@ -51,7 +51,6 @@ describe 'Copy as GFM', feature: true, js: true do To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/). - - Manage Git repositories with fine grained access controls that keep your code secure - Perform code reviews and enhance collaboration with merge requests @@ -66,6 +65,38 @@ describe 'Copy as GFM', feature: true, js: true do GFM ) + aggregate_failures('an accidentally selected empty element') do + gfm = '# Heading1' + + html = <<-HTML.strip_heredoc + <h1>Heading1</h1> + + <h2></h2> + HTML + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + + aggregate_failures('an accidentally selected other element') do + gfm = 'Test comment with **Markdown!**' + + html = <<-HTML.strip_heredoc + <li class="note"> + <div class="md"> + <p> + Test comment with <strong>Markdown!</strong> + </p> + </div> + </li> + + <li class="note"></li> + HTML + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + verify( 'InlineDiffFilter', @@ -96,7 +127,7 @@ describe 'Copy as GFM', feature: true, js: true do # issue link "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue)})", # issue link with note anchor - "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})", + "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})" ) verify( @@ -352,7 +383,6 @@ describe 'Copy as GFM', feature: true, js: true do <<-GFM.strip_heredoc, - Nested - - Lists GFM @@ -375,7 +405,6 @@ describe 'Copy as GFM', feature: true, js: true do <<-GFM.strip_heredoc, 1. Nested - 1. Numbered lists GFM @@ -479,7 +508,7 @@ describe 'Copy as GFM', feature: true, js: true do context 'from a blob' do before do visit namespace_project_blob_path(project.namespace, project, File.join('master', 'files/ruby/popen.rb')) - wait_for_ajax + wait_for_requests end context 'selecting one word of text' do @@ -521,7 +550,7 @@ describe 'Copy as GFM', feature: true, js: true do context 'from a GFM code block' do before do visit namespace_project_blob_path(project.namespace, project, File.join('markdown', 'doc/api/users.md')) - wait_for_ajax + wait_for_requests end context 'selecting one word of text' do diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index 7c9d522273b..b416bbd3c79 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -6,16 +6,18 @@ feature 'Cycle Analytics', feature: true, js: true do let(:project) { create(:project, :repository) } let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } let(:milestone) { create(:milestone, project: project) } - let(:mr) { create_merge_request_closing_issue(issue) } - let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha) } + let(:mr) { create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}") } + let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) } context 'as an allowed user' do context 'when project is new' do before do - project.team << [user, :master] + project.add_master(user) + login_as(user) + visit namespace_project_cycle_analytics_path(project.namespace, project) - wait_for_ajax + wait_for_requests end it 'shows introductory message' do @@ -30,9 +32,9 @@ feature 'Cycle Analytics', feature: true, js: true do context "when there's cycle analytics data" do before do - project.team << [user, :master] - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) + project.add_master(user) + create_cycle deploy_master @@ -70,7 +72,7 @@ feature 'Cycle Analytics', feature: true, js: true do project.team << [user, :master] login_as(user) visit namespace_project_cycle_analytics_path(project.namespace, project) - wait_for_ajax + wait_for_requests end it 'shows the content in Spanish' do @@ -85,7 +87,7 @@ feature 'Cycle Analytics', feature: true, js: true do context "as a guest" do before do - project.team << [guest, :guest] + project.add_guest(guest) allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) create_cycle @@ -93,7 +95,7 @@ feature 'Cycle Analytics', feature: true, js: true do login_as(guest) visit namespace_project_cycle_analytics_path(project.namespace, project) - wait_for_ajax + wait_for_requests end it 'needs permissions to see restricted stages' do @@ -137,6 +139,6 @@ feature 'Cycle Analytics', feature: true, js: true do def click_stage(stage_name) find('.stage-nav li', text: stage_name).click - wait_for_ajax + wait_for_requests end end diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb index c977f266296..0764044260e 100644 --- a/spec/features/dashboard/activity_spec.rb +++ b/spec/features/dashboard/activity_spec.rb @@ -5,7 +5,7 @@ RSpec.describe 'Dashboard Activity', feature: true do login_as(create :user) visit activity_dashboard_path end - - it_behaves_like "it has an RSS button with current_user's private token" - it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + + it_behaves_like "it has an RSS button with current_user's RSS token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end diff --git a/spec/features/dashboard/datetime_on_tooltips_spec.rb b/spec/features/dashboard/datetime_on_tooltips_spec.rb index 0e9e3f78be2..1793e323588 100644 --- a/spec/features/dashboard/datetime_on_tooltips_spec.rb +++ b/spec/features/dashboard/datetime_on_tooltips_spec.rb @@ -15,7 +15,7 @@ feature 'Tooltips on .timeago dates', feature: true, js: true do login_as user visit user_path(user) - wait_for_ajax() + wait_for_requests() page.find('.js-timeago').hover end @@ -32,7 +32,7 @@ feature 'Tooltips on .timeago dates', feature: true, js: true do login_as user visit user_snippets_path(user) - wait_for_ajax() + wait_for_requests() page.find('.js-timeago.snippet-created-ago').hover end diff --git a/spec/features/dashboard/group_spec.rb b/spec/features/dashboard/group_spec.rb index 1d4b86ed4b4..8e20fdec8ad 100644 --- a/spec/features/dashboard/group_spec.rb +++ b/spec/features/dashboard/group_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Dashboard Group', feature: true do it 'creates new group', js: true do visit dashboard_groups_path - click_link 'New group' + find('.btn-new').trigger('click') new_path = 'Samurai' new_description = 'Tokugawa Shogunate' diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb index 52b4d82e856..b0e2953dda2 100644 --- a/spec/features/dashboard/groups_list_spec.rb +++ b/spec/features/dashboard/groups_list_spec.rb @@ -23,7 +23,7 @@ describe 'Dashboard Groups page', js: true, feature: true do it 'filters groups' do fill_in 'filter_groups', with: group.name - wait_for_ajax + wait_for_requests expect(page).to have_content(group.full_name) expect(page).not_to have_content(nested_group.full_name) @@ -32,10 +32,10 @@ describe 'Dashboard Groups page', js: true, feature: true do it 'resets search when user cleans the input' do fill_in 'filter_groups', with: group.name - wait_for_ajax + wait_for_requests fill_in 'filter_groups', with: "" - wait_for_ajax + wait_for_requests expect(page).to have_content(group.full_name) expect(page).to have_content(nested_group.full_name) diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index 7a132dba1e9..20f1cd9f65d 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -2,30 +2,28 @@ require 'spec_helper' RSpec.describe 'Dashboard Issues', feature: true do let(:current_user) { create :user } - let(:public_project) { create(:empty_project, :public) } - let(:project) do - create(:empty_project) do |project| - project.team << [current_user, :master] - end - end - + let!(:public_project) { create(:empty_project, :public) } + let(:project) { create(:empty_project) } + let(:project_with_issues_disabled) { create(:empty_project, :issues_disabled) } let!(:authored_issue) { create :issue, author: current_user, project: project } let!(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project } let!(:assigned_issue) { create :issue, assignees: [current_user], project: project } let!(:other_issue) { create :issue, project: project } before do + [project, project_with_issues_disabled].each { |project| project.team << [current_user, :master] } login_as(current_user) - visit issues_dashboard_path(assignee_id: current_user.id) end - it 'shows issues assigned to current user' do - expect(page).to have_content(assigned_issue.title) - expect(page).not_to have_content(authored_issue.title) - expect(page).not_to have_content(other_issue.title) - end + describe 'issues' do + it 'shows issues assigned to current user' do + expect(page).to have_content(assigned_issue.title) + expect(page).not_to have_content(authored_issue.title) + expect(page).not_to have_content(other_issue.title) + end +<<<<<<< HEAD it 'shows checkmark when unassigned is selected for assignee', js: true do find('.js-assignee-search').click find('li', text: 'Unassigned').click @@ -42,26 +40,56 @@ RSpec.describe 'Dashboard Issues', feature: true do find('.dropdown-menu-author li a', match: :first, text: current_user.to_reference).click find('.js-author-search', match: :first).click +======= + it 'shows checkmark when unassigned is selected for assignee', js: true do + find('.js-assignee-search').click + find('li', text: 'Unassigned').click + find('.js-assignee-search').click +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - page.within '.dropdown-menu-user' do - expect(find('.dropdown-menu-author li a.is-active', match: :first, text: current_user.to_reference)).to be_visible + expect(find('li[data-user-id="0"] a.is-active')).to be_visible end - expect(page).to have_content(authored_issue.title) - expect(page).to have_content(authored_issue_on_public_project.title) - expect(page).not_to have_content(assigned_issue.title) - expect(page).not_to have_content(other_issue.title) - end + it 'shows issues when current user is author', js: true do + find('#assignee_id', visible: false).set('') + find('.js-author-search', match: :first).click + + expect(find('li[data-user-id="null"] a.is-active')).to be_visible + + find('.dropdown-menu-author li a', match: :first, text: current_user.to_reference).click + find('.js-author-search', match: :first).click - it 'shows all issues' do - click_link('Reset filters') + page.within '.dropdown-menu-user' do + expect(find('.dropdown-menu-author li a.is-active', match: :first, text: current_user.to_reference)).to be_visible + end - expect(page).to have_content(authored_issue.title) - expect(page).to have_content(authored_issue_on_public_project.title) - expect(page).to have_content(assigned_issue.title) - expect(page).to have_content(other_issue.title) + expect(page).to have_content(authored_issue.title) + expect(page).to have_content(authored_issue_on_public_project.title) + expect(page).not_to have_content(assigned_issue.title) + expect(page).not_to have_content(other_issue.title) + end + + it 'shows all issues' do + click_link('Reset filters') + + expect(page).to have_content(authored_issue.title) + expect(page).to have_content(authored_issue_on_public_project.title) + expect(page).to have_content(assigned_issue.title) + expect(page).to have_content(other_issue.title) + end + + it_behaves_like "it has an RSS button with current_user's RSS token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end - it_behaves_like "it has an RSS button with current_user's private token" - it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + describe 'new issue dropdown' do + it 'shows projects only with issues feature enabled', js: true do + find('.new-project-item-select-button').trigger('click') + + page.within('.select2-results') do + expect(page).to have_content(project.name_with_namespace) + expect(page).not_to have_content(project_with_issues_disabled.name_with_namespace) + end + end + end end diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index 508ca38d7e5..9cebe52c444 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -2,16 +2,28 @@ require 'spec_helper' describe 'Dashboard Merge Requests' do let(:current_user) { create :user } - let(:project) do - create(:empty_project) do |project| - project.add_master(current_user) - end - end + let(:project) { create(:empty_project) } + let(:project_with_merge_requests_disabled) { create(:empty_project, :merge_requests_disabled) } before do + [project, project_with_merge_requests_disabled].each { |project| project.team << [current_user, :master] } + login_as(current_user) end + describe 'new merge request dropdown' do + before { visit merge_requests_dashboard_path } + + it 'shows projects only with merge requests feature enabled', js: true do + find('.new-project-item-select-button').trigger('click') + + page.within('.select2-results') do + expect(page).to have_content(project.name_with_namespace) + expect(page).not_to have_content(project_with_merge_requests_disabled.name_with_namespace) + end + end + end + it 'should show an empty state' do visit merge_requests_dashboard_path(assignee_id: current_user.id) diff --git a/spec/features/dashboard/milestone_filter_spec.rb b/spec/features/dashboard/milestone_filter_spec.rb new file mode 100644 index 00000000000..b5b92c36895 --- /dev/null +++ b/spec/features/dashboard/milestone_filter_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe 'Dashboard > milestone filter', :feature, :js do + let(:user) { create(:user) } + let(:project) { create(:project, name: 'test', namespace: user.namespace) } + let(:milestone) { create(:milestone, title: "v1.0", project: project) } + let(:milestone2) { create(:milestone, title: "v2.0", project: project) } + let!(:issue) { create :issue, author: user, project: project, milestone: milestone } + let!(:issue2) { create :issue, author: user, project: project, milestone: milestone2 } + + before do + login_as(user) + visit issues_dashboard_path(author_id: user.id) + end + + context 'default state' do + it 'shows issues with Any Milestone' do + page.all('.issue-info').each do |issue_info| + expect(issue_info.text).to match(/v\d.0/) + end + end + end + + context 'filtering by milestone' do + milestone_select = '.js-milestone-select' + + before do + find(milestone_select).click + wait_for_requests + + page.within('.dropdown-content') do + click_link 'v1.0' + end + + find(milestone_select).click + wait_for_requests + end + + it 'shows issues with Milestone v1.0' do + expect(find('.issues-list')).to have_selector('.issue', count: 1) + expect(find('.dropdown-content')).to have_selector('a.is-active', count: 1) + end + + it 'should not change active Milestone unless clicked' do + expect(find('.dropdown-content')).to have_selector('a.is-active', count: 1) + + # open & close dropdown + find('.dropdown-menu-close').click + + expect(find('.milestone-filter')).not_to have_selector('.dropdown.open') + + find(milestone_select).click + + expect(find('.dropdown-content')).to have_selector('a.is-active', count: 1) + expect(find('.dropdown-content a.is-active')).to have_content('v1.0') + end + end +end diff --git a/spec/features/dashboard/project_member_activity_index_spec.rb b/spec/features/dashboard/project_member_activity_index_spec.rb index 16c214ae060..cdf919af9b5 100644 --- a/spec/features/dashboard/project_member_activity_index_spec.rb +++ b/spec/features/dashboard/project_member_activity_index_spec.rb @@ -11,7 +11,7 @@ feature 'Project member activity', feature: true, js: true do def visit_activities_and_wait_with_event(event_type) Event.create(project: project, author_id: user.id, action: event_type) visit activity_namespace_project_path(project.namespace, project) - wait_for_ajax + wait_for_requests end subject { page.find(".event-title").text } diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index f1789fc9d43..3568954a548 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -3,10 +3,11 @@ require 'spec_helper' RSpec.describe 'Dashboard Projects', feature: true do let(:user) { create(:user) } let(:project) { create(:project, name: "awesome stuff") } + let(:project2) { create(:project, :public, name: 'Community project') } before do project.team << [user, :developer] - login_as user + login_as(user) end it 'shows the project the user in a member of in the list' do @@ -14,6 +15,26 @@ RSpec.describe 'Dashboard Projects', feature: true do expect(page).to have_content('awesome stuff') end + it 'shows the last_activity_at attribute as the update date' do + now = Time.now + project.update_column(:last_activity_at, now) + + visit dashboard_projects_path + + expect(page).to have_xpath("//time[@datetime='#{now.getutc.iso8601}']") + end + + context 'when on Starred projects tab' do + it 'shows only starred projects' do + user.toggle_star(project2) + + visit(starred_dashboard_projects_path) + + expect(page).not_to have_content(project.name) + expect(page).to have_content(project2.name) + end + end + describe "with a pipeline", redis: true do let!(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.sha) } @@ -31,5 +52,5 @@ RSpec.describe 'Dashboard Projects', feature: true do end end - it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end diff --git a/spec/features/dashboard/shortcuts_spec.rb b/spec/features/dashboard/shortcuts_spec.rb index 4c9adcabe34..349b948eaee 100644 --- a/spec/features/dashboard/shortcuts_spec.rb +++ b/spec/features/dashboard/shortcuts_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Dashboard shortcuts', feature: true, js: true do +feature 'Dashboard shortcuts', :feature, :js do context 'logged in' do before do login_as :user @@ -8,21 +8,21 @@ feature 'Dashboard shortcuts', feature: true, js: true do end scenario 'Navigate to tabs' do - find('body').native.send_keys([:shift, 'P']) - - check_page_title('Projects') - - find('body').native.send_key([:shift, 'I']) + find('body').send_keys([:shift, 'I']) check_page_title('Issues') - find('body').native.send_key([:shift, 'M']) + find('body').send_keys([:shift, 'M']) check_page_title('Merge Requests') - find('body').native.send_keys([:shift, 'T']) + find('body').send_keys([:shift, 'T']) check_page_title('Todos') + + find('body').send_keys([:shift, 'P']) + + check_page_title('Projects') end end @@ -32,17 +32,20 @@ feature 'Dashboard shortcuts', feature: true, js: true do end scenario 'Navigate to tabs' do - find('body').native.send_keys([:shift, 'P']) - - expect(page).to have_content('No projects found') - - find('body').native.send_keys([:shift, 'G']) + find('body').send_keys([:shift, 'G']) + find('.nothing-here-block') expect(page).to have_content('No public groups') - find('body').native.send_keys([:shift, 'S']) + find('body').send_keys([:shift, 'S']) + find('.nothing-here-block') expect(page).to have_selector('.snippets-list-holder') + + find('body').send_keys([:shift, 'P']) + + find('.nothing-here-block') + expect(page).to have_content('No projects found') end end diff --git a/spec/features/dashboard_issues_spec.rb b/spec/features/dashboard_issues_spec.rb index ad60fb2c74f..1c53f6dff06 100644 --- a/spec/features/dashboard_issues_spec.rb +++ b/spec/features/dashboard_issues_spec.rb @@ -53,10 +53,10 @@ describe "Dashboard Issues filtering", feature: true, js: true do auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) - expect(params).to include('private_token' => [user.private_token]) + expect(params).to include('rss_token' => [user.rss_token]) expect(params).to include('milestone_title' => ['']) expect(params).to include('assignee_id' => [user.id.to_s]) - expect(auto_discovery_params).to include('private_token' => [user.private_token]) + expect(auto_discovery_params).to include('rss_token' => [user.rss_token]) expect(auto_discovery_params).to include('milestone_title' => ['']) expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) end diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb index 76c77e0bc5f..c4d5077e5e1 100644 --- a/spec/features/expand_collapse_diffs_spec.rb +++ b/spec/features/expand_collapse_diffs_spec.rb @@ -5,6 +5,11 @@ feature 'Expand and collapse diffs', js: true, feature: true do let(:project) { create(:project, :repository) } before do + # Set the limits to those when these specs were written, to avoid having to + # update the test repo every time we change them. + allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes) + allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(10.kilobytes) + login_as :admin # Ensure that undiffable.md is in .gitattributes @@ -36,7 +41,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do visit namespace_project_commit_path(project.namespace, project, project.commit(branch), anchor: "#{large_diff[:id]}_0_1") execute_script('window.location.reload()') - wait_for_ajax + wait_for_requests expect(large_diff).to have_selector('.code') expect(large_diff).not_to have_selector('.nothing-here-block') @@ -50,7 +55,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do visit namespace_project_commit_path(project.namespace, project, project.commit(branch), anchor: large_diff[:id]) execute_script('window.location.reload()') - wait_for_ajax + wait_for_requests expect(large_diff).to have_selector('.code') expect(large_diff).not_to have_selector('.nothing-here-block') @@ -62,18 +67,6 @@ feature 'Expand and collapse diffs', js: true, feature: true do expect(small_diff).not_to have_selector('.nothing-here-block') end - it 'collapses large diffs by default' do - expect(large_diff).not_to have_selector('.code') - expect(large_diff).to have_selector('.nothing-here-block') - end - - it 'collapses large diffs for renamed files by default' do - expect(large_diff_renamed).not_to have_selector('.code') - expect(large_diff_renamed).to have_selector('.nothing-here-block') - expect(large_diff_renamed).to have_selector('.js-file-title .deletion') - expect(large_diff_renamed).to have_selector('.js-file-title .addition') - end - it 'shows non-renderable diffs as such immediately, regardless of their size' do expect(undiffable).not_to have_selector('.code') expect(undiffable).to have_selector('.nothing-here-block') @@ -94,7 +87,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('.click-to-expand').click - wait_for_ajax + wait_for_requests end it 'shows the old content' do @@ -116,7 +109,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do find('.js-file-title', match: :first) # Click `large_diff.md` title all('.diff-toggle-caret')[1].click - wait_for_ajax + wait_for_requests end it 'makes a request to get the content' do @@ -139,7 +132,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do large_diff.find('.add-diff-note').click large_diff.find('.note-textarea').send_keys comment_text large_diff.find_button('Comment').click - wait_for_ajax + wait_for_requests end it 'adds the comment' do @@ -160,7 +153,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do find('.js-file-title', match: :first) # Click `large_diff.md` title all('.diff-toggle-caret')[1].click - wait_for_ajax + wait_for_requests end it 'shows the diff content' do @@ -216,7 +209,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do expect(page).to have_no_content('No longer a symlink') find('.click-to-expand').click - wait_for_ajax + wait_for_requests expect(page).to have_content('No longer a symlink') end @@ -273,7 +266,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do expect(page).to have_content('too_large_image.jpg') find('.note-textarea') - wait_for_ajax + wait_for_requests execute_script('window.ajaxUris = []; $(document).ajaxSend(function(event, xhr, settings) { ajaxUris.push(settings.url) });') end diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb index 9828cb179a7..d4284ed099b 100644 --- a/spec/features/explore/groups_list_spec.rb +++ b/spec/features/explore/groups_list_spec.rb @@ -23,7 +23,7 @@ describe 'Explore Groups page', :js, :feature do it 'filters groups' do fill_in 'filter_groups', with: group.name - wait_for_ajax + wait_for_requests expect(page).to have_content(group.full_name) expect(page).not_to have_content(public_group.full_name) @@ -32,10 +32,10 @@ describe 'Explore Groups page', :js, :feature do it 'resets search when user cleans the input' do fill_in 'filter_groups', with: group.name - wait_for_ajax + wait_for_requests fill_in 'filter_groups', with: "" - wait_for_ajax + wait_for_requests expect(page).to have_content(group.full_name) expect(page).to have_content(public_group.full_name) diff --git a/spec/features/explore/new_menu_spec.rb b/spec/features/explore/new_menu_spec.rb new file mode 100644 index 00000000000..15a6354211b --- /dev/null +++ b/spec/features/explore/new_menu_spec.rb @@ -0,0 +1,172 @@ +require 'spec_helper' + +feature 'Top Plus Menu', feature: true, js: true do + let(:user) { create :user } + let(:guest_user) { create :user} + let(:group) { create(:group) } + let(:project) { create(:project, :repository, creator: user, namespace: user.namespace) } + let(:public_project) { create(:project, :public) } + + before do + group.add_owner(user) + group.add_guest(guest_user) + + project.add_guest(guest_user) + end + + context 'used by full user' do + before do + login_as(user) + end + + scenario 'click on New project shows new project page' do + visit root_dashboard_path + + click_topmenuitem("New project") + + expect(page).to have_content('Project path') + expect(page).to have_content('Project name') + end + + scenario 'click on New group shows new group page' do + visit root_dashboard_path + + click_topmenuitem("New group") + + expect(page).to have_content('Group path') + expect(page).to have_content('Group name') + end + + scenario 'click on New snippet shows new snippet page' do + visit root_dashboard_path + + click_topmenuitem("New snippet") + + expect(page).to have_content('New Snippet') + expect(page).to have_content('Title') + end + + scenario 'click on New issue shows new issue page' do + visit namespace_project_path(project.namespace, project) + + click_topmenuitem("New issue") + + expect(page).to have_content('New Issue') + expect(page).to have_content('Title') + end + + scenario 'click on New merge request shows new merge request page' do + visit namespace_project_path(project.namespace, project) + + click_topmenuitem("New merge request") + + expect(page).to have_content('New Merge Request') + expect(page).to have_content('Source branch') + expect(page).to have_content('Target branch') + end + + scenario 'click on New project snippet shows new snippet page' do + visit namespace_project_path(project.namespace, project) + + page.within '.header-content' do + find('.header-new-dropdown-toggle').trigger('click') + expect(page).to have_selector('.header-new.dropdown.open', count: 1) + find('.header-new-project-snippet a').trigger('click') + end + + expect(page).to have_content('New Snippet') + expect(page).to have_content('Title') + end + + scenario 'Click on New subgroup shows new group page' do + visit group_path(group) + + click_topmenuitem("New subgroup") + + expect(page).to have_content('Group path') + expect(page).to have_content('Group name') + end + + scenario 'Click on New project in group shows new project page' do + visit group_path(group) + + page.within '.header-content' do + find('.header-new-dropdown-toggle').trigger('click') + expect(page).to have_selector('.header-new.dropdown.open', count: 1) + find('.header-new-group-project a').trigger('click') + end + + expect(page).to have_content('Project path') + expect(page).to have_content('Project name') + end + end + + context 'used by guest user' do + before do + login_as(guest_user) + end + + scenario 'click on New issue shows new issue page' do + visit namespace_project_path(project.namespace, project) + + click_topmenuitem("New issue") + + expect(page).to have_content('New Issue') + expect(page).to have_content('Title') + end + + scenario 'has no New merge request menu item' do + visit namespace_project_path(project.namespace, project) + + hasnot_topmenuitem("New merge request") + end + + scenario 'has no New project snippet menu item' do + visit namespace_project_path(project.namespace, project) + + expect(find('.header-new.dropdown')).not_to have_selector('.header-new-project-snippet') + end + + scenario 'public project has no New Issue Button' do + visit namespace_project_path(public_project.namespace, public_project) + + hasnot_topmenuitem("New issue") + end + + scenario 'public project has no New merge request menu item' do + visit namespace_project_path(public_project.namespace, public_project) + + hasnot_topmenuitem("New merge request") + end + + scenario 'public project has no New project snippet menu item' do + visit namespace_project_path(public_project.namespace, public_project) + + expect(find('.header-new.dropdown')).not_to have_selector('.header-new-project-snippet') + end + + scenario 'has no New subgroup menu item' do + visit group_path(group) + + hasnot_topmenuitem("New subgroup") + end + + scenario 'has no New project for group menu item' do + visit group_path(group) + + expect(find('.header-new.dropdown')).not_to have_selector('.header-new-group-project') + end + end + + def click_topmenuitem(item_name) + page.within '.header-content' do + find('.header-new-dropdown-toggle').trigger('click') + expect(page).to have_selector('.header-new.dropdown.open', count: 1) + click_link item_name + end + end + + def hasnot_topmenuitem(item_name) + expect(find('.header-new.dropdown')).not_to have_content(item_name) + end +end diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/gitlab_flavored_markdown_spec.rb index 005a029a393..55092412340 100644 --- a/spec/features/gitlab_flavored_markdown_spec.rb +++ b/spec/features/gitlab_flavored_markdown_spec.rb @@ -49,8 +49,6 @@ describe "GitLab Flavored Markdown", feature: true do end describe "for issues", feature: true, js: true do - include WaitForVueResource - before do @other_issue = create(:issue, author: @user, diff --git a/spec/features/groups/activity_spec.rb b/spec/features/groups/activity_spec.rb index 3b481cba424..81f9c103e95 100644 --- a/spec/features/groups/activity_spec.rb +++ b/spec/features/groups/activity_spec.rb @@ -11,8 +11,8 @@ feature 'Group activity page', feature: true do visit path end - it_behaves_like "it has an RSS button with current_user's private token" - it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + it_behaves_like "it has an RSS button with current_user's RSS token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end context 'when signed out' do @@ -20,7 +20,7 @@ feature 'Group activity page', feature: true do visit path end - it_behaves_like "it has an RSS button without a private token" - it_behaves_like "an autodiscoverable RSS feed without a private token" + it_behaves_like "it has an RSS button without an RSS token" + it_behaves_like "an autodiscoverable RSS feed without an RSS token" end end diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index 45f57845c74..d6b88542ef7 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -12,15 +12,15 @@ feature 'Group issues page', feature: true do context 'when signed in' do let(:user) { user_in_group } - it_behaves_like "it has an RSS button with current_user's private token" - it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + it_behaves_like "it has an RSS button with current_user's RSS token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end context 'when signed out' do let(:user) { nil } - it_behaves_like "it has an RSS button without a private token" - it_behaves_like "an autodiscoverable RSS feed without a private token" + it_behaves_like "it has an RSS button without an RSS token" + it_behaves_like "an autodiscoverable RSS feed without an RSS token" end end @@ -33,7 +33,7 @@ feature 'Group issues page', feature: true do it 'filters by only group users' do click_button('Assignee') - wait_for_ajax + wait_for_requests expect(find('.dropdown-menu-assignee')).to have_link(user.name) expect(find('.dropdown-menu-assignee')).not_to have_link(user2.name) diff --git a/spec/features/groups/members/sorting_spec.rb b/spec/features/groups/members/sorting_spec.rb index 608aedd3471..902d3f789ff 100644 --- a/spec/features/groups/members/sorting_spec.rb +++ b/spec/features/groups/members/sorting_spec.rb @@ -68,7 +68,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending') end - scenario 'sorts by recent sign in' do + scenario 'sorts by recent sign in', :redis do visit_members_list(sort: :recent_sign_in) expect(first_member).to include(owner.name) @@ -76,7 +76,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in') end - scenario 'sorts by oldest sign in' do + scenario 'sorts by oldest sign in', :redis do visit_members_list(sort: :oldest_sign_in) expect(first_member).to include(developer.name) diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb index fb39693e8ca..d3c49c37374 100644 --- a/spec/features/groups/show_spec.rb +++ b/spec/features/groups/show_spec.rb @@ -11,7 +11,7 @@ feature 'Group show page', feature: true do visit path end - it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end context 'when signed out' do @@ -19,6 +19,6 @@ feature 'Group show page', feature: true do visit path end - it_behaves_like "an autodiscoverable RSS feed without a private token" + it_behaves_like "an autodiscoverable RSS feed without an RSS token" end end diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb index f3ec80bb149..414838fa22e 100644 --- a/spec/features/issuables/issuable_list_spec.rb +++ b/spec/features/issuables/issuable_list_spec.rb @@ -52,6 +52,9 @@ describe 'issuable list', feature: true do create(:issue, project: project, author: user) else create(:merge_request, source_project: project, source_branch: generate(:branch)) + source_branch = FFaker::Name.name + pipeline = create(:ci_empty_pipeline, project: project, ref: source_branch, status: %w(running failed success).sample, sha: 'any') + create(:merge_request, title: FFaker::Lorem.sentence, source_project: project, source_branch: source_branch, head_pipeline: pipeline) end 2.times do diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb index 853632614c4..81ae54c7a10 100644 --- a/spec/features/issues/award_emoji_spec.rb +++ b/spec/features/issues/award_emoji_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' describe 'Awards Emoji', feature: true do - include WaitForVueResource - let!(:project) { create(:project, :public) } let!(:user) { create(:user) } let(:issue) do @@ -22,7 +20,7 @@ describe 'Awards Emoji', feature: true do # The `heart_tip` emoji is not valid anymore so we need to skip validation issue.award_emoji.build(user: user, name: 'heart_tip').save!(validate: false) visit namespace_project_issue_path(project.namespace, project, issue) - wait_for_vue_resource + wait_for_requests end # Regression test: https://gitlab.com/gitlab-org/gitlab-ce/issues/29529 @@ -36,19 +34,19 @@ describe 'Awards Emoji', feature: true do before do visit namespace_project_issue_path(project.namespace, project, issue) - wait_for_vue_resource + wait_for_requests end it 'increments the thumbsdown emoji', js: true do find('[data-name="thumbsdown"]').click - wait_for_ajax + wait_for_requests expect(thumbsdown_emoji).to have_text("1") end context 'click the thumbsup emoji' do it 'increments the thumbsup emoji', js: true do find('[data-name="thumbsup"]').click - wait_for_ajax + wait_for_requests expect(thumbsup_emoji).to have_text("1") end @@ -60,7 +58,7 @@ describe 'Awards Emoji', feature: true do context 'click the thumbsdown emoji' do it 'increments the thumbsdown emoji', js: true do find('[data-name="thumbsdown"]').click - wait_for_ajax + wait_for_requests expect(thumbsdown_emoji).to have_text("1") end @@ -113,7 +111,7 @@ describe 'Awards Emoji', feature: true do click_button 'Comment' end - wait_for_ajax + wait_for_requests end def thumbsup_emoji @@ -143,6 +141,6 @@ describe 'Awards Emoji', feature: true do find('[data-name="smiley"]').click end - wait_for_ajax + wait_for_requests end end diff --git a/spec/features/issues/award_spec.rb b/spec/features/issues/award_spec.rb index 08e3f99e29f..fcf22dd5033 100644 --- a/spec/features/issues/award_spec.rb +++ b/spec/features/issues/award_spec.rb @@ -6,12 +6,10 @@ feature 'Issue awards', js: true, feature: true do let(:issue) { create(:issue, project: project) } describe 'logged in' do - include WaitForVueResource - before do login_as(user) visit namespace_project_issue_path(project.namespace, project, issue) - wait_for_vue_resource + wait_for_requests end it 'adds award to issue' do @@ -41,11 +39,9 @@ feature 'Issue awards', js: true, feature: true do end describe 'logged out' do - include WaitForVueResource - before do visit namespace_project_issue_path(project.namespace, project, issue) - wait_for_vue_resource + wait_for_requests end it 'does not see award menu button' do diff --git a/spec/features/issues/bulk_assignment_labels_spec.rb b/spec/features/issues/bulk_assignment_labels_spec.rb index 1de50d6d77e..95b4930cd32 100644 --- a/spec/features/issues/bulk_assignment_labels_spec.rb +++ b/spec/features/issues/bulk_assignment_labels_spec.rb @@ -18,13 +18,13 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'can bulk assign' do before do - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end context 'a label' do context 'to all issues' do before do - check 'check_all_issues' + check 'check-all-issues' open_labels_dropdown ['bug'] update_issues end @@ -52,7 +52,7 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'multiple labels' do context 'to all issues' do before do - check 'check_all_issues' + check 'check-all-issues' open_labels_dropdown %w(bug feature) update_issues end @@ -86,9 +86,10 @@ feature 'Issues > Labels bulk assignment', feature: true do before do issue2.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) - check 'check_all_issues' + enable_bulk_update + check 'check-all-issues' + open_labels_dropdown ['bug'] update_issues end @@ -107,9 +108,8 @@ feature 'Issues > Labels bulk assignment', feature: true do issue2.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) - - check 'check_all_issues' + enable_bulk_update + check 'check-all-issues' unmark_labels_in_dropdown %w(bug feature) update_issues end @@ -127,8 +127,7 @@ feature 'Issues > Labels bulk assignment', feature: true do issue1.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) - + enable_bulk_update check_issue issue1 unmark_labels_in_dropdown ['bug'] update_issues @@ -147,8 +146,7 @@ feature 'Issues > Labels bulk assignment', feature: true do issue2.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) - + enable_bulk_update check_issue issue1 check_issue issue2 unmark_labels_in_dropdown ['bug'] @@ -171,14 +169,15 @@ feature 'Issues > Labels bulk assignment', feature: true do before do issue1.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it 'keeps labels' do expect(find("#issue_#{issue1.id}")).to have_content 'bug' expect(find("#issue_#{issue2.id}")).to have_content 'feature' - check 'check_all_issues' + check 'check-all-issues' + open_milestone_dropdown(['First Release']) update_issues @@ -192,14 +191,13 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'setting a milestone and adding another label' do before do issue1.labels << bug - - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it 'keeps existing label and new label is present' do expect(find("#issue_#{issue1.id}")).to have_content 'bug' - check 'check_all_issues' + check 'check-all-issues' open_milestone_dropdown ['First Release'] open_labels_dropdown ['feature'] update_issues @@ -218,7 +216,7 @@ feature 'Issues > Labels bulk assignment', feature: true do issue1.labels << feature issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it 'keeps existing label and new label is present' do @@ -226,7 +224,8 @@ feature 'Issues > Labels bulk assignment', feature: true do expect(find("#issue_#{issue1.id}")).to have_content 'bug' expect(find("#issue_#{issue2.id}")).to have_content 'feature' - check 'check_all_issues' + check 'check-all-issues' + open_milestone_dropdown ['First Release'] unmark_labels_in_dropdown ['feature'] update_issues @@ -248,7 +247,7 @@ feature 'Issues > Labels bulk assignment', feature: true do issue1.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it 'keeps labels' do @@ -257,7 +256,7 @@ feature 'Issues > Labels bulk assignment', feature: true do expect(find("#issue_#{issue2.id}")).to have_content 'feature' expect(find("#issue_#{issue2.id}")).to have_content 'First Release' - check 'check_all_issues' + check 'check-all-issues' open_milestone_dropdown(['No Milestone']) update_issues @@ -272,8 +271,7 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'toggling checked issues' do before do issue1.labels << bug - - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it do @@ -298,15 +296,15 @@ feature 'Issues > Labels bulk assignment', feature: true do issue1.labels << feature issue2.labels << bug - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it 'applies label from filtered results' do - check 'check_all_issues' + check 'check-all-issues' - page.within('.issues_bulk_update') do - click_button 'Labels' - wait_for_ajax + page.within('.issues-bulk-update') do + click_button 'Select labels' + wait_for_requests expect(find('.dropdown-menu-labels li', text: 'bug')).to have_css('.is-active') expect(find('.dropdown-menu-labels li', text: 'feature')).to have_css('.is-indeterminate') @@ -340,16 +338,17 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'cannot bulk assign labels' do it do - expect(page).not_to have_css '.check_all_issues' + expect(page).not_to have_button 'Edit Issues' + expect(page).not_to have_css '.check-all-issues' expect(page).not_to have_css '.issue-check' end end end def open_milestone_dropdown(items = []) - page.within('.issues_bulk_update') do - click_button 'Milestone' - wait_for_ajax + page.within('.issues-bulk-update') do + click_button 'Select milestone' + wait_for_requests items.map do |item| click_link item end @@ -357,9 +356,9 @@ feature 'Issues > Labels bulk assignment', feature: true do end def open_labels_dropdown(items = [], unmark = false) - page.within('.issues_bulk_update') do - click_button 'Labels' - wait_for_ajax + page.within('.issues-bulk-update') do + click_button 'Select labels' + wait_for_requests items.map do |item| click_link item end @@ -391,7 +390,12 @@ feature 'Issues > Labels bulk assignment', feature: true do end def update_issues - click_button 'Update issues' - wait_for_ajax + click_button 'Update all' + wait_for_requests + end + + def enable_bulk_update + visit namespace_project_issues_path(project.namespace, project) + click_button 'Edit Issues' end end diff --git a/spec/features/issues/create_branch_merge_request_spec.rb b/spec/features/issues/create_branch_merge_request_spec.rb index 44c19275ae5..1d7d8d291b2 100644 --- a/spec/features/issues/create_branch_merge_request_spec.rb +++ b/spec/features/issues/create_branch_merge_request_spec.rb @@ -16,7 +16,7 @@ feature 'Create Branch/Merge Request Dropdown on issue page', feature: true, js: select_dropdown_option('create-mr') - wait_for_ajax + wait_for_requests expect(page).to have_content("created branch 1-cherry-coloured-funk") expect(page).to have_content("mentioned in merge request !1") @@ -32,7 +32,7 @@ feature 'Create Branch/Merge Request Dropdown on issue page', feature: true, js: select_dropdown_option('create-branch') - wait_for_ajax + wait_for_requests expect(page).to have_selector('.dropdown-toggle-text ', text: '1-cherry-coloured-funk') expect(current_path).to eq namespace_project_tree_path(project.namespace, project, '1-cherry-coloured-funk') diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb index 4d38df05928..44353d880c2 100644 --- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -157,6 +157,25 @@ describe 'Dropdown assignee', :feature, :js do end end + describe 'selecting from dropdown without Ajax call' do + before do + Gitlab::Testing::RequestBlockerMiddleware.block_requests! + filtered_search.set('assignee:') + end + + after do + Gitlab::Testing::RequestBlockerMiddleware.allow_requests! + end + + it 'selects current user' do + find('#js-dropdown-assignee .filter-dropdown-item', text: user.username).click + + expect(page).to have_css(js_dropdown_assignee, visible: false) + expect_tokens([{ name: 'assignee', value: user.username }]) + expect_filtered_search_input_empty + end + end + describe 'input has existing content' do it 'opens assignee dropdown with existing search term' do filtered_search.set('searchTerm assignee:') diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb index 8a43512fa3f..6b707c4be4a 100644 --- a/spec/features/issues/filtered_search/dropdown_author_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb @@ -16,7 +16,7 @@ describe 'Dropdown author', js: true, feature: true do end sleep 0.5 - wait_for_ajax + wait_for_requests end def dropdown_author_size @@ -135,6 +135,25 @@ describe 'Dropdown author', js: true, feature: true do end end + describe 'selecting from dropdown without Ajax call' do + before do + Gitlab::Testing::RequestBlockerMiddleware.block_requests! + filtered_search.set('author:') + end + + after do + Gitlab::Testing::RequestBlockerMiddleware.allow_requests! + end + + it 'selects current user' do + find('#js-dropdown-author .filter-dropdown-item', text: user.username).click + + expect(page).to have_css(js_dropdown_author, visible: false) + expect_tokens([{ name: 'author', value: user.username }]) + expect_filtered_search_input_empty + end + end + describe 'input has existing content' do it 'opens author dropdown with existing search term' do filtered_search.set('searchTerm author:') diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index a8f4e2d7e10..863f8f75cd8 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -6,7 +6,7 @@ describe 'Filter issues', js: true, feature: true do let!(:group) { create(:group) } let!(:project) { create(:project, group: group) } - let!(:user) { create(:user, username: 'joe') } + let!(:user) { create(:user, username: 'joe', name: 'Joe') } let!(:user2) { create(:user, username: 'jane') } let!(:label) { create(:label, project: project) } let!(:wontfix) { create(:label, project: project, title: "Won't fix") } @@ -761,7 +761,7 @@ describe 'Filter issues', js: true, feature: true do sort_toggle.click find('.filtered-search-wrapper .dropdown-menu li a', text: 'Oldest updated').click - wait_for_ajax + wait_for_requests expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title) end @@ -777,26 +777,26 @@ describe 'Filter issues', js: true, feature: true do end it 'open state' do - find('.issues-state-filters a', text: 'Closed').click - wait_for_ajax + find('.issues-state-filters [data-state="closed"]').click + wait_for_requests - find('.issues-state-filters a', text: 'Open').click - wait_for_ajax + find('.issues-state-filters [data-state="opened"]').click + wait_for_requests expect(page).to have_selector('.issues-list .issue', count: 4) end it 'closed state' do - find('.issues-state-filters a', text: 'Closed').click - wait_for_ajax + find('.issues-state-filters [data-state="closed"]').click + wait_for_requests expect(page).to have_selector('.issues-list .issue', count: 1) expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(closed_issue.title) end it 'all state' do - find('.issues-state-filters a', text: 'All').click - wait_for_ajax + find('.issues-state-filters [data-state="all"]').click + wait_for_requests expect(page).to have_selector('.issues-list .issue', count: 5) end @@ -810,10 +810,10 @@ describe 'Filter issues', js: true, feature: true do auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) - expect(params).to include('private_token' => [user.private_token]) + expect(params).to include('rss_token' => [user.rss_token]) expect(params).to include('milestone_title' => [milestone.title]) expect(params).to include('assignee_id' => [user.id.to_s]) - expect(auto_discovery_params).to include('private_token' => [user.private_token]) + expect(auto_discovery_params).to include('rss_token' => [user.rss_token]) expect(auto_discovery_params).to include('milestone_title' => [milestone.title]) expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) end @@ -825,10 +825,10 @@ describe 'Filter issues', js: true, feature: true do auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) - expect(params).to include('private_token' => [user.private_token]) + expect(params).to include('rss_token' => [user.rss_token]) expect(params).to include('milestone_title' => [milestone.title]) expect(params).to include('assignee_id' => [user.id.to_s]) - expect(auto_discovery_params).to include('private_token' => [user.private_token]) + expect(auto_discovery_params).to include('rss_token' => [user.rss_token]) expect(auto_discovery_params).to include('milestone_title' => [milestone.title]) expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) end diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb index 08fe3b4553b..09f228bcf49 100644 --- a/spec/features/issues/filtered_search/recent_searches_spec.rb +++ b/spec/features/issues/filtered_search/recent_searches_spec.rb @@ -3,17 +3,17 @@ require 'spec_helper' describe 'Recent searches', js: true, feature: true do include FilteredSearchHelpers - let!(:group) { create(:group) } - let!(:project) { create(:project, group: group) } - let!(:user) { create(:user) } + let(:project_1) { create(:empty_project, :public) } + let(:project_2) { create(:empty_project, :public) } + let(:project_1_local_storage_key) { "#{project_1.full_path}-issue-recent-searches" } before do Capybara.ignore_hidden_elements = false - project.add_master(user) - group.add_developer(user) - create(:issue, project: project) - login_as(user) + create(:issue, project: project_1) + create(:issue, project: project_2) + # Visit any fast-loading page so we can clear local storage without a DOM exception + visit '/404' remove_recent_searches end @@ -22,7 +22,7 @@ describe 'Recent searches', js: true, feature: true do end it 'searching adds to recent searches' do - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_issues_path(project_1.namespace, project_1) input_filtered_search('foo', submit: true) input_filtered_search('bar', submit: true) @@ -35,8 +35,8 @@ describe 'Recent searches', js: true, feature: true do end it 'visiting URL with search params adds to recent searches' do - visit namespace_project_issues_path(project.namespace, project, label_name: 'foo', search: 'bar') - visit namespace_project_issues_path(project.namespace, project, label_name: 'qux', search: 'garply') + visit namespace_project_issues_path(project_1.namespace, project_1, label_name: 'foo', search: 'bar') + visit namespace_project_issues_path(project_1.namespace, project_1, label_name: 'qux', search: 'garply') items = all('.filtered-search-history-dropdown-item', visible: false) @@ -46,9 +46,9 @@ describe 'Recent searches', js: true, feature: true do end it 'saved recent searches are restored last on the list' do - set_recent_searches('["saved1", "saved2"]') + set_recent_searches(project_1_local_storage_key, '["saved1", "saved2"]') - visit namespace_project_issues_path(project.namespace, project, search: 'foo') + visit namespace_project_issues_path(project_1.namespace, project_1, search: 'foo') items = all('.filtered-search-history-dropdown-item', visible: false) @@ -58,9 +58,27 @@ describe 'Recent searches', js: true, feature: true do expect(items[2].text).to eq('saved2') end + it 'searches are scoped to projects' do + visit namespace_project_issues_path(project_1.namespace, project_1) + + input_filtered_search('foo', submit: true) + input_filtered_search('bar', submit: true) + + visit namespace_project_issues_path(project_2.namespace, project_2) + + input_filtered_search('more', submit: true) + input_filtered_search('things', submit: true) + + items = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items.count).to eq(2) + expect(items[0].text).to eq('things') + expect(items[1].text).to eq('more') + end + it 'clicking item fills search input' do - set_recent_searches('["foo", "bar"]') - visit namespace_project_issues_path(project.namespace, project) + set_recent_searches(project_1_local_storage_key, '["foo", "bar"]') + visit namespace_project_issues_path(project_1.namespace, project_1) all('.filtered-search-history-dropdown-item', visible: false)[0].trigger('click') wait_for_filtered_search('foo') @@ -69,8 +87,8 @@ describe 'Recent searches', js: true, feature: true do end it 'clear recent searches button, clears recent searches' do - set_recent_searches('["foo"]') - visit namespace_project_issues_path(project.namespace, project) + set_recent_searches(project_1_local_storage_key, '["foo"]') + visit namespace_project_issues_path(project_1.namespace, project_1) items_before = all('.filtered-search-history-dropdown-item', visible: false) @@ -83,8 +101,8 @@ describe 'Recent searches', js: true, feature: true do end it 'shows flash error when failed to parse saved history' do - set_recent_searches('fail') - visit namespace_project_issues_path(project.namespace, project) + set_recent_searches(project_1_local_storage_key, 'fail') + visit namespace_project_issues_path(project_1.namespace, project_1) expect(find('.flash-alert')).to have_text('An error occured while parsing recent searches') end diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb index 96e87c82d2c..ff32b0c7d11 100644 --- a/spec/features/issues/filtered_search/visual_tokens_spec.rb +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' describe 'Visual tokens', js: true, feature: true do include FilteredSearchHelpers + include WaitForRequests let!(:project) { create(:empty_project) } let!(:user) { create(:user, name: 'administrator', username: 'root') } @@ -33,7 +34,7 @@ describe 'Visual tokens', js: true, feature: true do describe 'editing author token' do before do input_filtered_search('author:@root assignee:none', submit: false) - first('.tokens-container .filtered-search-token').double_click + first('.tokens-container .filtered-search-token').click end it 'opens author dropdown' do @@ -70,7 +71,8 @@ describe 'Visual tokens', js: true, feature: true do end it 'changes value in visual token' do - expect(first('.tokens-container .filtered-search-token .value').text).to eq("@#{user_rock.username}") + wait_for_requests + expect(first('.tokens-container .filtered-search-token .value').text).to eq("#{user_rock.name}") end it 'moves input to the right' do @@ -329,7 +331,7 @@ describe 'Visual tokens', js: true, feature: true do it 'does not tokenize incomplete token' do filtered_search.send_keys('author:') - find('#content-body').click + find('body').click token = page.all('.tokens-container .js-visual-token')[1] expect_filtered_search_input_empty diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 677a725f107..3062cc18722 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -1,8 +1,9 @@ require 'rails_helper' -describe 'New/edit issue', feature: true, js: true do +describe 'New/edit issue', :feature, :js do include GitlabRoutingHelper include ActionView::Helpers::JavaScriptHelper + include FormHelper let!(:project) { create(:project) } let!(:user) { create(:user)} @@ -23,11 +24,65 @@ describe 'New/edit issue', feature: true, js: true do visit new_namespace_project_issue_path(project.namespace, project) end +<<<<<<< HEAD describe 'single assignee' do +======= + describe 'shorten users API pagination limit (CE)' do +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 before do + # Using `allow_any_instance_of`/`and_wrap_original`, `original` would + # somehow refer to the very block we defined to _wrap_ that method, instead of + # the original method, resulting in infinite recurison when called. + # This is likely a bug with helper modules included into dynamically generated view classes. + # To work around this, we have to hold on to and call to the original implementation manually. + original_issue_dropdown_options = FormHelper.instance_method(:issue_dropdown_options) + allow_any_instance_of(FormHelper).to receive(:issue_dropdown_options).and_wrap_original do |original, *args| + options = original_issue_dropdown_options.bind(original.receiver).call(*args) + options[:data][:per_page] = 2 + + options + end + + visit new_namespace_project_issue_path(project.namespace, project) + click_button 'Unassigned' +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + end + + it 'should display selected users even if they are not part of the original API call' do + find('.dropdown-input-field').native.send_keys user2.name + + page.within '.dropdown-menu-user' do + expect(page).to have_content user2.name + click_link user2.name + end + +<<<<<<< HEAD + click_button user2.name +======= + find('.js-assignee-search').click + find('.js-dropdown-input-clear').click +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + + page.within '.dropdown-menu-user' do + expect(page).to have_content user.name + expect(find('.dropdown-menu-user a.is-active').first(:xpath, '..')['data-user-id']).to eq(user2.id.to_s) + end + end + end + + describe 'single assignee (CE)' do + before do + click_button 'Unassigned' + +<<<<<<< HEAD +======= + wait_for_requests end it 'unselects other assignees when unassigned is selected' do @@ -41,6 +96,7 @@ describe 'New/edit issue', feature: true, js: true do click_link 'Unassigned' end +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match('0') end @@ -67,6 +123,9 @@ describe 'New/edit issue', feature: true, js: true do expect(find('a', text: 'Assign to me')).to be_visible click_button 'Unassigned' + + wait_for_requests + page.within '.dropdown-menu-user' do click_link user2.name end @@ -151,7 +210,11 @@ describe 'New/edit issue', feature: true, js: true do it 'correctly updates the selected user when changing assignee' do click_button 'Unassigned' +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 page.within '.dropdown-menu-user' do click_link user.name @@ -216,6 +279,37 @@ describe 'New/edit issue', feature: true, js: true do end end + describe 'sub-group project' do + let(:group) { create(:group) } + let(:nested_group_1) { create(:group, parent: group) } + let(:sub_group_project) { create(:empty_project, group: nested_group_1) } + + before do + sub_group_project.add_master(user) + + visit new_namespace_project_issue_path(sub_group_project.namespace, sub_group_project) + end + + it 'creates new label from dropdown' do + click_button 'Labels' + + click_link 'Create new label' + + page.within '.dropdown-new-label' do + fill_in 'new_label_name', with: 'test label' + first('.suggest-colors-dropdown a').click + + click_button 'Create' + + wait_for_requests + end + + page.within '.dropdown-menu-labels' do + expect(page).to have_link 'test label' + end + end + end + def before_for_selector(selector) js = <<-JS.strip_heredoc (function(selector) { diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index ad29911248f..350473437a8 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -11,7 +11,7 @@ feature 'GFM autocomplete', feature: true, js: true do login_as(user) visit namespace_project_issue_path(project.namespace, project, issue) - wait_for_ajax + wait_for_requests end it 'opens autocomplete menu when field starts with text' do @@ -40,7 +40,7 @@ feature 'GFM autocomplete', feature: true, js: true do expect(page).to have_selector('.atwho-container') - wait_for_ajax + wait_for_requests expect(find('#at-view-58')).not_to have_selector('.cur:first-of-type') end @@ -80,7 +80,7 @@ feature 'GFM autocomplete', feature: true, js: true do expect(page).to have_selector('.atwho-container') - wait_for_ajax + wait_for_requests expect(find('#at-view-64')).to have_selector('.cur:first-of-type') end @@ -93,7 +93,7 @@ feature 'GFM autocomplete', feature: true, js: true do expect(page).to have_selector('.atwho-container') - wait_for_ajax + wait_for_requests expect(find('#at-view-64')).to have_content(user.name) end @@ -106,7 +106,7 @@ feature 'GFM autocomplete', feature: true, js: true do expect(page).to have_selector('.atwho-container') - wait_for_ajax + wait_for_requests expect(find('#at-view-58')).to have_selector('.cur:first-of-type') end diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 0de0f93089a..96c24750250 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -23,7 +23,7 @@ feature 'Issue Sidebar', feature: true do find('.block.assignee .edit-link').click - wait_for_ajax + wait_for_requests end it 'shows author in assignee dropdown' do @@ -37,7 +37,7 @@ feature 'Issue Sidebar', feature: true do find('.dropdown-input-field').native.send_keys user2.name sleep 1 # Required to wait for end of input delay - wait_for_ajax + wait_for_requests expect(page).to have_content(user2.name) end @@ -48,7 +48,7 @@ feature 'Issue Sidebar', feature: true do click_button 'assign yourself' - wait_for_ajax + wait_for_requests find('.block.assignee .edit-link').click @@ -57,6 +57,23 @@ feature 'Issue Sidebar', feature: true do expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name) end end + + it 'keeps your filtered term after filtering and dismissing the dropdown' do + find('.dropdown-input-field').native.send_keys user2.name + + wait_for_requests + + page.within '.dropdown-menu-user' do + expect(page).not_to have_content 'Unassigned' + click_link user2.name + end + + find('.js-right-sidebar').click + find('.block.assignee .edit-link').click + + expect(page.all('.dropdown-menu-user li').length).to eq(1) + expect(find('.dropdown-input-field').value).to eq(user2.name) + end end context 'as a allowed user' do diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index 6c09903a2f6..e75bf059218 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -38,9 +38,11 @@ feature 'issue move to another project' do end scenario 'moving issue to another project', js: true do - find('#move_to_project_id', visible: false).set(new_project.id) + find('#issuable-move', visible: false).set(new_project.id) click_button('Save changes') + wait_for_requests + expect(current_url).to include project_path(new_project) expect(page).to have_content("Text with #{cross_reference}#{mr.to_reference}") @@ -51,7 +53,7 @@ feature 'issue move to another project' do scenario 'searching project dropdown', js: true do new_project_search.team << [user, :reporter] - page.within '.js-move-dropdown' do + page.within '.detail-page-description' do first('.select2-choice').click end @@ -69,7 +71,7 @@ feature 'issue move to another project' do background { another_project.team << [user, :guest] } scenario 'browsing projects in projects select' do - click_link 'Select project' + click_link 'Move to a different project' page.within '.select2-results' do expect(page).to have_content 'No project' diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb index 80f57906506..937bd277f2a 100644 --- a/spec/features/issues/note_polling_spec.rb +++ b/spec/features/issues/note_polling_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Issue notes polling', :feature, :js do + include NoteInteractionHelpers + let(:project) { create(:empty_project, :public) } let(:issue) { create(:issue, project: project) } @@ -48,7 +50,11 @@ feature 'Issue notes polling', :feature, :js do end it 'when editing but have not changed anything, and an update comes in, show the updated content in the textarea' do +<<<<<<< HEAD find("#note_#{existing_note.id} .js-note-edit").click +======= + click_edit_action(existing_note) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_field("note[note]", with: note_text) @@ -58,6 +64,7 @@ feature 'Issue notes polling', :feature, :js do end it 'when editing but you changed some things, and an update comes in, show a warning' do +<<<<<<< HEAD find("#note_#{existing_note.id} .js-note-edit").click expect(page).to have_field("note[note]", with: note_text) @@ -74,6 +81,23 @@ feature 'Issue notes polling', :feature, :js do expect(page).to have_field("note[note]", with: note_text) +======= + click_edit_action(existing_note) + + expect(page).to have_field("note[note]", with: note_text) + + find("#note_#{existing_note.id} .js-note-text").set('something random') + update_note(existing_note, updated_text) + + expect(page).to have_selector(".alert") + end + + it 'when editing but you changed some things, an update comes in, and you press cancel, show the updated content' do + click_edit_action(existing_note) + + expect(page).to have_field("note[note]", with: note_text) + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 find("#note_#{existing_note.id} .js-note-text").set('something random') update_note(existing_note, updated_text) @@ -95,6 +119,15 @@ feature 'Issue notes polling', :feature, :js do login_as(user2) visit namespace_project_issue_path(project.namespace, project, issue) end +<<<<<<< HEAD + + it 'has .original-note-content to compare against' do + expect(page).to have_selector("#note_#{existing_note.id}", text: note_text) + expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false) + + update_note(existing_note, updated_text) + +======= it 'has .original-note-content to compare against' do expect(page).to have_selector("#note_#{existing_note.id}", text: note_text) @@ -102,6 +135,7 @@ feature 'Issue notes polling', :feature, :js do update_note(existing_note, updated_text) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text) expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false) end @@ -128,4 +162,12 @@ feature 'Issue notes polling', :feature, :js do note.update(note: new_text) page.execute_script('notes.refresh();') end + + def click_edit_action(note) + note_element = find("#note_#{note.id}") + + open_more_actions_dropdown(note) + + note_element.find('.js-note-edit').click + end end diff --git a/spec/features/issues/notes_on_issues_spec.rb b/spec/features/issues/notes_on_issues_spec.rb index a4035324d2b..5b84c400e3e 100644 --- a/spec/features/issues/notes_on_issues_spec.rb +++ b/spec/features/issues/notes_on_issues_spec.rb @@ -15,7 +15,11 @@ describe 'Create notes on issues', :js, :feature do fill_in 'note[note]', with: note_text click_button 'Comment' +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end it 'creates a note with reference and cross references the issue' do diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb index b250fa2ed3c..8595847d313 100644 --- a/spec/features/issues/update_issues_spec.rb +++ b/spec/features/issues/update_issues_spec.rb @@ -14,7 +14,8 @@ feature 'Multiple issue updating from issues#index', feature: true do it 'sets to closed' do visit namespace_project_issues_path(project.namespace, project) - find('#check_all_issues').click + click_button 'Edit Issues' + find('#check-all-issues').click find('.js-issue-status').click find('.dropdown-menu-status a', text: 'Closed').click @@ -26,7 +27,8 @@ feature 'Multiple issue updating from issues#index', feature: true do create_closed visit namespace_project_issues_path(project.namespace, project, state: 'closed') - find('#check_all_issues').click + click_button 'Edit Issues' + find('#check-all-issues').click find('.js-issue-status').click find('.dropdown-menu-status a', text: 'Open').click @@ -39,7 +41,8 @@ feature 'Multiple issue updating from issues#index', feature: true do it 'updates to current user' do visit namespace_project_issues_path(project.namespace, project) - find('#check_all_issues').click + click_button 'Edit Issues' + find('#check-all-issues').click click_update_assignee_button find('.dropdown-menu-user-link', text: user.username).click @@ -54,7 +57,8 @@ feature 'Multiple issue updating from issues#index', feature: true do create_assigned visit namespace_project_issues_path(project.namespace, project) - find('#check_all_issues').click + click_button 'Edit Issues' + find('#check-all-issues').click click_update_assignee_button click_link 'Unassigned' @@ -69,8 +73,9 @@ feature 'Multiple issue updating from issues#index', feature: true do it 'updates milestone' do visit namespace_project_issues_path(project.namespace, project) - find('#check_all_issues').click - find('.issues_bulk_update .js-milestone-select').click + click_button 'Edit Issues' + find('#check-all-issues').click + find('.issues-bulk-update .js-milestone-select').click find('.dropdown-menu-milestone a', text: milestone.title).click click_update_issues_button @@ -84,8 +89,9 @@ feature 'Multiple issue updating from issues#index', feature: true do expect(first('.issue')).to have_content milestone.title - find('#check_all_issues').click - find('.issues_bulk_update .js-milestone-select').click + click_button 'Edit Issues' + find('#check-all-issues').click + find('.issues-bulk-update .js-milestone-select').click find('.dropdown-menu-milestone a', text: "No Milestone").click click_update_issues_button @@ -108,11 +114,11 @@ feature 'Multiple issue updating from issues#index', feature: true do def click_update_assignee_button find('.js-update-assignee').click - wait_for_ajax + wait_for_requests end def click_update_issues_button - find('.update_selected_issues').click - wait_for_ajax + find('.update-selected-issues').click + wait_for_requests end end diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index 4cd6c1171ac..d14c319707c 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -18,7 +18,7 @@ feature 'Issues > User uses slash commands', feature: true, js: true do end after do - wait_for_ajax + wait_for_requests end describe 'adding a due date from note' do diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 396a923082d..eecc565d2bd 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -30,13 +30,6 @@ describe 'Issues', feature: true do it 'opens new issue popup' do expect(page).to have_content("Issue ##{issue.iid}") end - - describe 'fill in' do - before do - fill_in 'issue_title', with: 'bug 345' - fill_in 'issue_description', with: 'bug description' - end - end end describe 'Editing issue assignee' do @@ -384,7 +377,7 @@ describe 'Issues', feature: true do previous_token = find('input#issue_email').value find('.incoming-email-token-reset').trigger('click') - wait_for_ajax + wait_for_requests expect(page).to have_no_field('issue_email', with: previous_token) new_token = project1.new_issue_address(@user.reload) @@ -430,7 +423,7 @@ describe 'Issues', feature: true do expect(page).to have_content 'No assignee' end - # wait_for_ajax does not work with vue-resource at the moment + # wait_for_requests does not work with vue-resource at the moment sleep 1 expect(issue.reload.assignees).to be_empty @@ -557,18 +550,11 @@ describe 'Issues', feature: true do expect(page).to have_content milestone.title end end - - describe 'removing assignee' do - let(:user2) { create(:user) } - - before do - issue.assignees << user2 - issue.save - end - end end describe 'new issue' do + let!(:issue) { create(:issue, project: project) } + context 'by unauthenticated user' do before do logout @@ -675,7 +661,7 @@ describe 'Issues', feature: true do click_button date.day end - wait_for_ajax + wait_for_requests expect(find('.value').text).to have_content date.strftime('%b %-d, %Y') end @@ -691,7 +677,7 @@ describe 'Issues', feature: true do click_button date.day end - wait_for_ajax + wait_for_requests expect(page).to have_no_content 'No due date' @@ -703,8 +689,6 @@ describe 'Issues', feature: true do end describe 'title issue#show', js: true do - include WaitForVueResource - it 'updates the title', js: true do issue = create(:issue, author: @user, assignees: [@user], project: project, title: 'new title') @@ -714,7 +698,7 @@ describe 'Issues', feature: true do issue.update(title: "updated title") - wait_for_vue_resource + wait_for_requests expect(page).to have_text("updated title") end end diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index 11d417c253d..c82e8c03343 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -41,7 +41,7 @@ feature 'Login', feature: true do expect(page).to have_content('Your account has been blocked.') end - it 'does not update Devise trackable attributes' do + it 'does not update Devise trackable attributes', :redis do user = create(:user, :blocked) expect { login_with(user) }.not_to change { user.reload.sign_in_count } @@ -55,7 +55,7 @@ feature 'Login', feature: true do expect(page).to have_content('Invalid Login or password.') end - it 'does not update Devise trackable attributes' do + it 'does not update Devise trackable attributes', :redis do expect { login_with(User.ghost) }.not_to change { User.ghost.reload.sign_in_count } end end diff --git a/spec/features/merge_requests/closes_issues_spec.rb b/spec/features/merge_requests/closes_issues_spec.rb index ee0880a1e2f..fd44c20132c 100644 --- a/spec/features/merge_requests/closes_issues_spec.rb +++ b/spec/features/merge_requests/closes_issues_spec.rb @@ -1,8 +1,11 @@ require 'spec_helper' feature 'Merge Request closing issues message', feature: true, js: true do +<<<<<<< HEAD include WaitForAjax +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 let(:user) { create(:user) } let(:project) { create(:project, :public) } let(:issue_1) { create(:issue, project: project)} @@ -25,7 +28,11 @@ feature 'Merge Request closing issues message', feature: true, js: true do login_as user visit namespace_project_merge_request_path(project.namespace, project, merge_request) +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end context 'not closing or mentioning any issue' do diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb index 43977ad2fc5..27e2d5d16f3 100644 --- a/spec/features/merge_requests/conflicts_spec.rb +++ b/spec/features/merge_requests/conflicts_spec.rb @@ -23,13 +23,13 @@ feature 'Merge request conflict resolution', js: true, feature: true do end click_button 'Commit conflict resolution' - wait_for_ajax + wait_for_requests expect(page).to have_content('All merge conflicts were resolved') merge_request.reload_diff click_on 'Changes' - wait_for_ajax + wait_for_requests within find('.diff-file', text: 'files/ruby/popen.rb') do expect(page).to have_selector('.line_content.new', text: "vars = { 'PWD' => path }") @@ -53,23 +53,23 @@ feature 'Merge request conflict resolution', js: true, feature: true do within find('.files-wrapper .diff-file', text: 'files/ruby/popen.rb') do click_button 'Edit inline' - wait_for_ajax + wait_for_requests execute_script('ace.edit($(".files-wrapper .diff-file pre")[0]).setValue("One morning");') end within find('.files-wrapper .diff-file', text: 'files/ruby/regex.rb') do click_button 'Edit inline' - wait_for_ajax + wait_for_requests execute_script('ace.edit($(".files-wrapper .diff-file pre")[1]).setValue("Gregor Samsa woke from troubled dreams");') end click_button 'Commit conflict resolution' - wait_for_ajax + wait_for_requests expect(page).to have_content('All merge conflicts were resolved') merge_request.reload_diff click_on 'Changes' - wait_for_ajax + wait_for_requests expect(page).to have_content('One morning') expect(page).to have_content('Gregor Samsa woke from troubled dreams') @@ -100,7 +100,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do context 'in Parallel view mode' do before do - click_link('conflicts', href: /\/conflicts\Z/) + click_link('conflicts', href: /\/conflicts\Z/) click_button 'Side-by-side' end @@ -126,21 +126,21 @@ feature 'Merge request conflict resolution', js: true, feature: true do it 'conflicts are resolved in Edit inline mode' do within find('.files-wrapper .diff-file', text: 'files/markdown/ruby-style-guide.md') do - wait_for_ajax + wait_for_requests execute_script('ace.edit($(".files-wrapper .diff-file pre")[0]).setValue("Gregor Samsa woke from troubled dreams");') end click_button 'Commit conflict resolution' - wait_for_ajax + wait_for_requests expect(page).to have_content('All merge conflicts were resolved') merge_request.reload_diff click_on 'Changes' - wait_for_ajax + wait_for_requests click_link 'Expand all' - wait_for_ajax + wait_for_requests expect(page).to have_content('Gregor Samsa woke from troubled dreams') end @@ -151,7 +151,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do 'conflict-too-large' => 'when the conflicts contain a large file', 'conflict-binary-file' => 'when the conflicts contain a binary file', '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', + 'conflict-non-utf8' => 'when the conflicts contain a non-UTF-8 file' }.freeze UNRESOLVABLE_CONFLICTS.each do |source_branch, description| @@ -171,7 +171,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do it 'shows an error if the conflicts page is visited directly' do visit current_url + '/conflicts' - wait_for_ajax + wait_for_requests expect(find('#conflicts')).to have_content('Please try to resolve them locally.') end diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb index f1b3e7f158c..82987c768d1 100644 --- a/spec/features/merge_requests/create_new_mr_spec.rb +++ b/spec/features/merge_requests/create_new_mr_spec.rb @@ -1,8 +1,6 @@ require 'spec_helper' feature 'Create New Merge Request', feature: true, js: true do - include WaitForVueResource - let(:user) { create(:user) } let(:project) { create(:project, :public) } @@ -146,7 +144,7 @@ feature 'Create New Merge Request', feature: true, js: true do page.within('.merge-request') do click_link 'Pipelines' - wait_for_vue_resource + wait_for_requests expect(page).to have_content "##{pipeline.id}" end diff --git a/spec/features/merge_requests/deleted_source_branch_spec.rb b/spec/features/merge_requests/deleted_source_branch_spec.rb index 01e5e4f3a05..1723fb7d365 100644 --- a/spec/features/merge_requests/deleted_source_branch_spec.rb +++ b/spec/features/merge_requests/deleted_source_branch_spec.rb @@ -32,7 +32,7 @@ describe 'Deleted source branch', feature: true, js: true do end click_on 'Changes' - wait_for_ajax + wait_for_requests expect(page).to have_selector('.diffs.tab-pane .nothing-here-block') expect(page).to have_content('Source branch does not exist.') diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb index b2e170513c4..e23dc2cd940 100644 --- a/spec/features/merge_requests/diff_notes_avatars_spec.rb +++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Diff note avatars', feature: true, js: true do + include NoteInteractionHelpers + 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") } @@ -60,7 +62,7 @@ feature 'Diff note avatars', feature: true, js: true do click_button 'Comment' - wait_for_ajax + wait_for_requests end visit namespace_project_merge_request_path(project.namespace, project, merge_request) @@ -76,7 +78,7 @@ feature 'Diff note avatars', feature: true, js: true do before do visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: view) - wait_for_ajax + wait_for_requests end it 'shows note avatar' do @@ -91,7 +93,7 @@ feature 'Diff note avatars', feature: true, js: true do page.within find("[id='#{position.line_code(project.repository)}']") do find('.diff-notes-collapse').click - expect(first('img.js-diff-comment-avatar')["title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}") + expect(first('img.js-diff-comment-avatar')["data-original-title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}") end end @@ -110,11 +112,13 @@ feature 'Diff note avatars', feature: true, js: true do end it 'removes avatar when note is deleted' do + open_more_actions_dropdown(note) + page.within find(".note-row-#{note.id}") do find('.js-note-delete').click end - wait_for_ajax + wait_for_requests page.within find("[id='#{position.line_code(project.repository)}']") do expect(page).not_to have_selector('img.js-diff-comment-avatar') @@ -129,7 +133,7 @@ feature 'Diff note avatars', feature: true, js: true do click_button 'Comment' - wait_for_ajax + wait_for_requests end page.within find("[id='#{position.line_code(project.repository)}']") do @@ -148,7 +152,7 @@ feature 'Diff note avatars', feature: true, js: true do find('.js-comment-button').trigger 'click' - wait_for_ajax + wait_for_requests end end @@ -166,7 +170,7 @@ feature 'Diff note avatars', feature: true, js: true do visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: view) - wait_for_ajax + wait_for_requests end it 'shows extra comment count' do diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb index 0e23c3a8849..4d549f3bdbb 100644 --- a/spec/features/merge_requests/diff_notes_resolve_spec.rb +++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb @@ -275,7 +275,7 @@ feature 'Diff notes resolve', feature: true, js: true do end page.within '.line-resolve-all-container' do - page.find('.discussion-next-btn').click + page.find('.discussion-next-btn').trigger('click') end expect(page.evaluate_script("$('body').scrollTop()")).to be > 0 diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index 4860a2a7498..44013df3ea0 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -68,9 +68,14 @@ feature 'Diffs URL', js: true, feature: true do let(:merge_request) { create(:merge_request_with_diffs, source_project: forked_project, target_project: project, author: author_user) } let(:changelog_id) { Digest::SHA1.hexdigest("CHANGELOG") } + before do + forked_project.repository.after_import + end + context 'as author' do it 'shows direct edit link' do login_as(author_user) + visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request) # Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax @@ -81,6 +86,7 @@ feature 'Diffs URL', js: true, feature: true do context 'as user who needs to fork' do it 'shows fork/cancel confirmation' do login_as(user) + visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request) # Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax diff --git a/spec/features/merge_requests/discussion_spec.rb b/spec/features/merge_requests/discussion_spec.rb index f59d0faa274..9db235f35ba 100644 --- a/spec/features/merge_requests/discussion_spec.rb +++ b/spec/features/merge_requests/discussion_spec.rb @@ -5,7 +5,7 @@ feature 'Merge Request Discussions', feature: true do login_as :admin end - context "Diff discussions" do + describe "Diff discussions" do let(:merge_request) { create(:merge_request, importing: true) } let(:project) { merge_request.source_project } let!(:old_merge_request_diff) { merge_request.merge_request_diffs.create(diff_refs: outdated_diff_refs) } @@ -43,9 +43,48 @@ feature 'Merge Request Discussions', feature: true do it 'shows a link to the outdated diff' do within(".discussion[data-discussion-id='#{outdated_discussion.id}']") do path = diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, diff_id: old_merge_request_diff.id, anchor: outdated_discussion.line_code) - expect(page).to have_link('an outdated diff', href: path) + expect(page).to have_link('an old version of the diff', href: path) end end end end + + describe 'Commit comments displayed in MR context', :js do + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + + shared_examples 'a functional discussion' do + let(:discussion_id) { note.discussion_id(merge_request) } + + it 'is displayed' do + expect(page).to have_css(".discussion[data-discussion-id='#{discussion_id}']") + end + + it 'can be replied to' do + within(".discussion[data-discussion-id='#{discussion_id}']") do + click_button 'Reply...' + fill_in 'note[note]', with: 'Test!' + click_button 'Comment' + + expect(page).to have_css('.note', count: 2) + end + end + end + + before(:each) do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + context 'a regular commit comment' do + let(:note) { create(:note_on_commit, project: project) } + + it_behaves_like 'a functional discussion' + end + + context 'a commit diff comment' do + let(:note) { create(:diff_note_on_commit, project: project) } + + it_behaves_like 'a functional discussion' + end + end end diff --git a/spec/features/merge_requests/edit_mr_spec.rb b/spec/features/merge_requests/edit_mr_spec.rb index ec87a99b3ab..875e5551d45 100644 --- a/spec/features/merge_requests/edit_mr_spec.rb +++ b/spec/features/merge_requests/edit_mr_spec.rb @@ -29,6 +29,22 @@ feature 'Edit Merge Request', feature: true do expect(page).to have_content 'Someone edited the merge request the same time you did' end +<<<<<<< HEAD +======= + it 'allows to unselect "Remove source branch"', js: true do + merge_request.update(merge_params: { 'force_remove_source_branch' => '1' }) + expect(merge_request.merge_params['force_remove_source_branch']).to be_truthy + + visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request) + uncheck 'Remove source branch when merge request is accepted' + + click_button 'Save changes' + + expect(page).to have_unchecked_field 'remove-source-branch-input' + expect(page).to have_content 'Remove source branch' + end + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 it 'should preserve description textarea height', js: true do long_description = %q( Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam ac ornare ligula, ut tempus arcu. Etiam ultricies accumsan dolor vitae faucibus. Donec at elit lacus. Mauris orci ante, aliquam quis lorem eget, convallis faucibus arcu. Aenean at pulvinar lacus. Ut viverra quam massa, molestie ornare tortor dignissim a. Suspendisse tristique pellentesque tellus, id lacinia metus elementum id. Nam tristique, arcu rhoncus faucibus viverra, lacus ipsum sagittis ligula, vitae convallis odio lacus a nibh. Ut tincidunt est purus, ac vestibulum augue maximus in. Suspendisse vel erat et mi ultricies semper. Pellentesque volutpat pellentesque consequat. diff --git a/spec/features/merge_requests/filter_merge_requests_spec.rb b/spec/features/merge_requests/filter_merge_requests_spec.rb index 2da60e9f4ad..d086be70d69 100644 --- a/spec/features/merge_requests/filter_merge_requests_spec.rb +++ b/spec/features/merge_requests/filter_merge_requests_spec.rb @@ -40,13 +40,13 @@ describe 'Filter merge requests', feature: true do end it 'does not change when closed link is clicked' do - find('.issues-state-filters a', text: "Closed").click + find('.issues-state-filters [data-state="closed"]').click expect_assignee_visual_tokens() end it 'does not change when all link is clicked' do - find('.issues-state-filters a', text: "All").click + find('.issues-state-filters [data-state="all"]').click expect_assignee_visual_tokens() end @@ -73,13 +73,13 @@ describe 'Filter merge requests', feature: true do end it 'does not change when closed link is clicked' do - find('.issues-state-filters a', text: "Closed").click + find('.issues-state-filters [data-state="closed"]').click expect_milestone_visual_tokens() end it 'does not change when all link is clicked' do - find('.issues-state-filters a', text: "All").click + find('.issues-state-filters [data-state="all"]').click expect_milestone_visual_tokens() end @@ -142,11 +142,9 @@ describe 'Filter merge requests', feature: true do expect_tokens([{ name: 'assignee', value: "@#{user.username}" }]) expect_filtered_search_input_empty - input_filtered_search_keys("label:~#{label.title} ") + input_filtered_search_keys("label:~#{label.title}") expect_mr_list_count(1) - - find("#state-opened[href=\"#{URI.parse(current_url).path}?assignee_username=#{user.username}&label_name%5B%5D=#{label.title}&scope=all&state=opened\"]") end context 'assignee and label', js: true do @@ -163,13 +161,13 @@ describe 'Filter merge requests', feature: true do end it 'does not change when closed link is clicked' do - find('.issues-state-filters a', text: "Closed").click + find('.issues-state-filters [data-state="closed"]').click expect_assignee_label_visual_tokens() end it 'does not change when all link is clicked' do - find('.issues-state-filters a', text: "All").click + find('.issues-state-filters [data-state="all"]').click expect_assignee_label_visual_tokens() end @@ -289,7 +287,7 @@ describe 'Filter merge requests', feature: true do page.within '.dropdown-menu-sort' do click_link 'Oldest created' end - wait_for_ajax + wait_for_requests page.within '.mr-list' do expect(page).to have_content('Frontend') diff --git a/spec/features/merge_requests/form_spec.rb b/spec/features/merge_requests/form_spec.rb index f8518f450dc..00ef1ffdddc 100644 --- a/spec/features/merge_requests/form_spec.rb +++ b/spec/features/merge_requests/form_spec.rb @@ -90,7 +90,7 @@ describe 'New/edit merge request', feature: true, js: true do page.within '.issuable-meta' do merge_request = MergeRequest.find_by(source_branch: 'fix') - expect(page).to have_text("Merge Request #{merge_request.to_reference}") + expect(page).to have_text("Merge request #{merge_request.to_reference}") # compare paths because the host differ in test expect(find_link(merge_request.to_reference)[:href]) .to end_with(merge_request_path(merge_request)) diff --git a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb index b79667a1a4c..4cb91900a55 100644 --- a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb +++ b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb @@ -4,16 +4,18 @@ feature 'Merge immediately', :feature, :js do let(:user) { create(:user) } let(:project) { create(:project, :public) } - let(:merge_request) do + let!(:merge_request) do create(:merge_request_with_diffs, source_project: project, author: user, - title: 'Bug NS-04') + title: 'Bug NS-04', + head_pipeline: pipeline, + source_branch: pipeline.ref) end let(:pipeline) do create(:ci_pipeline, project: project, - sha: merge_request.diff_head_sha, - ref: merge_request.source_branch) + ref: 'master', + sha: project.repository.commit('master').id) end before { project.team << [user, :master] } @@ -32,11 +34,17 @@ feature 'Merge immediately', :feature, :js do page.within '.mr-widget-body' do find('.dropdown-toggle').click - click_link 'Merge immediately' + Sidekiq::Testing.fake! do + click_link 'Merge immediately' +<<<<<<< HEAD expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress') +======= + expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress') +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - wait_for_ajax + wait_for_requests + end end end end diff --git a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb index b33d7f90a31..1114cb0d6bf 100644 --- a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb +++ b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb @@ -7,16 +7,20 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do let(:merge_request) do create(:merge_request_with_diffs, source_project: project, author: user, - title: 'Bug NS-04') + title: 'Bug NS-04', + merge_params: { force_remove_source_branch: '1' }) end let(:pipeline) do create(:ci_pipeline, project: project, sha: merge_request.diff_head_sha, - ref: merge_request.source_branch) + ref: merge_request.source_branch, + head_pipeline_of: merge_request) end - before { project.team << [user, :master] } + before do + project.add_master(user) + end context 'when there is active pipeline for merge request' do background do @@ -38,7 +42,11 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do click_button "Merge when pipeline succeeds" expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds." +<<<<<<< HEAD expect(page).to have_content "The source branch will be removed." +======= + expect(page).to have_content "The source branch will not be removed." +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_selector ".js-cancel-auto-merge" visit_merge_request(merge_request) # Needed to refresh the page expect(page).to have_content /enabled an automatic merge when the pipeline for \h{8} succeeds/i @@ -79,7 +87,8 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do source_project: project, title: 'Bug NS-04', author: user, - merge_user: user) + merge_user: user, + merge_params: { force_remove_source_branch: '1' }) end before do diff --git a/spec/features/merge_requests/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb index 449a60c1d05..3a11ea3c8b2 100644 --- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb +++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' feature 'Mini Pipeline Graph', :js, :feature do let(:user) { create(:user) } let(:project) { create(:project, :public) } - let(:merge_request) { create(:merge_request, source_project: project) } + let(:merge_request) { create(:merge_request, source_project: project, head_pipeline: pipeline) } let(:pipeline) { create(:ci_empty_pipeline, project: project, ref: 'master', status: 'running', sha: project.commit.id) } let(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') } @@ -12,13 +12,39 @@ feature 'Mini Pipeline Graph', :js, :feature do build.run login_as(user) - visit namespace_project_merge_request_path(project.namespace, project, merge_request) + visit_merge_request + end + + def visit_merge_request(format = :html) + visit namespace_project_merge_request_path(project.namespace, project, merge_request, format: format) end it 'should display a mini pipeline graph' do expect(page).to have_selector('.mr-widget-pipeline-graph') end + context 'as json' do + let(:artifacts_file1) { fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') } + let(:artifacts_file2) { fixture_file_upload(Rails.root + 'spec/fixtures/dk.png', 'image/png') } + + before do + create(:ci_build, pipeline: pipeline, artifacts_file: artifacts_file1) + create(:ci_build, pipeline: pipeline, when: 'manual') + end + + it 'avoids repeated database queries' do + before = ActiveRecord::QueryRecorder.new { visit_merge_request(:json) } + + create(:ci_build, pipeline: pipeline, artifacts_file: artifacts_file2) + create(:ci_build, pipeline: pipeline, when: 'manual') + + after = ActiveRecord::QueryRecorder.new { visit_merge_request(:json) } + + expect(before.count).to eq(after.count) + expect(before.cached_count).to eq(after.cached_count) + end + end + describe 'build list toggle' do let(:toggle) do find('.mini-pipeline-graph-dropdown-toggle') @@ -56,7 +82,7 @@ feature 'Mini Pipeline Graph', :js, :feature do before do toggle.click - wait_for_ajax + wait_for_requests end it 'should open when toggle is clicked' do @@ -85,7 +111,7 @@ feature 'Mini Pipeline Graph', :js, :feature do build_item.click find('.build-page') - expect(current_path).to eql(namespace_project_build_path(project.namespace, project, build)) + expect(current_path).to eql(namespace_project_job_path(project.namespace, project, build)) end it 'should show tooltip when hovered' do diff --git a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb index 187e927dac4..20d36db87e3 100644 --- a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb +++ b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb @@ -1,8 +1,11 @@ require 'spec_helper' feature 'Only allow merge requests to be merged if the pipeline succeeds', feature: true, js: true do +<<<<<<< HEAD include WaitForVueResource +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 let(:merge_request) { create(:merge_request_with_diffs) } let(:project) { merge_request.target_project } @@ -16,7 +19,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_button 'Merge' end @@ -28,7 +35,7 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch, - status: status) + status: status, head_pipeline_of: merge_request) end context 'when merge requests can only be merged if the pipeline succeeds' do @@ -42,7 +49,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'does not allow to merge immediately' do visit_merge_request(merge_request) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_button 'Merge when pipeline succeeds' expect(page).not_to have_button 'Select merge moment' @@ -55,7 +66,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'does not allow MR to be merged' do visit_merge_request(merge_request) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_css('button[disabled="disabled"]', text: 'Merge') expect(page).to have_content('Please retry the job or push a new commit to fix the failure.') @@ -68,7 +83,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'does not allow MR to be merged' do visit_merge_request(merge_request) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).not_to have_button 'Merge' expect(page).to have_content('Please retry the job or push a new commit to fix the failure.') @@ -81,7 +100,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_button 'Merge' end @@ -93,7 +116,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_button 'Merge' end @@ -111,7 +138,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged immediately' do visit_merge_request(merge_request) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_button 'Merge when pipeline succeeds' @@ -126,7 +157,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_button 'Merge' end @@ -138,7 +173,11 @@ feature 'Only allow merge requests to be merged if the pipeline succeeds', featu it 'allows MR to be merged' do visit_merge_request(merge_request) +<<<<<<< HEAD wait_for_vue_resource +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_button 'Merge' end diff --git a/spec/features/merge_requests/pipelines_spec.rb b/spec/features/merge_requests/pipelines_spec.rb index 99e283ac181..4c76004cb93 100644 --- a/spec/features/merge_requests/pipelines_spec.rb +++ b/spec/features/merge_requests/pipelines_spec.rb @@ -26,7 +26,7 @@ feature 'Pipelines for Merge Requests', feature: true, js: true do page.within('.merge-request-tabs') do click_link('Pipelines') end - wait_for_ajax + wait_for_requests expect(page).to have_selector('.pipeline-actions') end diff --git a/spec/features/merge_requests/update_merge_requests_spec.rb b/spec/features/merge_requests/update_merge_requests_spec.rb index 9ecc998785b..bcdfdf78a44 100644 --- a/spec/features/merge_requests/update_merge_requests_spec.rb +++ b/spec/features/merge_requests/update_merge_requests_spec.rb @@ -98,16 +98,18 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t end def change_status(text) - find('#check_all_issues').click + click_button 'Edit Merge Requests' + 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 + click_button 'Edit Merge Requests' + find('#check-all-issues').click find('.js-update-assignee').click - wait_for_ajax + wait_for_requests page.within '.dropdown-menu-user' do click_link text @@ -117,14 +119,15 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t end def change_milestone(text) - find('#check_all_issues').click - find('.issues_bulk_update .js-milestone-select').click + click_button 'Edit Merge Requests' + 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 + find('.update-selected-issues').click + wait_for_requests end end diff --git a/spec/features/merge_requests/user_posts_diff_notes_spec.rb b/spec/features/merge_requests/user_posts_diff_notes_spec.rb index 7756202e3f5..14bc549c9f9 100644 --- a/spec/features/merge_requests/user_posts_diff_notes_spec.rb +++ b/spec/features/merge_requests/user_posts_diff_notes_spec.rb @@ -73,7 +73,7 @@ feature 'Merge requests > User posts diff notes', :js do context 'with an unfolded line' do before(:each) do find('.js-unfold', match: :first).click - wait_for_ajax + wait_for_requests end # The first `.js-unfold` unfolds upwards, therefore the first @@ -122,7 +122,7 @@ feature 'Merge requests > User posts diff notes', :js do context 'with an unfolded line' do before(:each) do find('.js-unfold', match: :first).click - wait_for_ajax + wait_for_requests end # The first `.js-unfold` unfolds upwards, therefore the first @@ -213,7 +213,7 @@ feature 'Merge requests > User posts diff notes', :js do write_comment_on_line(line_holder, diff_side) click_button 'Comment' - wait_for_ajax + wait_for_requests assert_comment_persistence(line_holder, asset_form_reset: asset_form_reset) end diff --git a/spec/features/merge_requests/user_posts_notes_spec.rb b/spec/features/merge_requests/user_posts_notes_spec.rb index 7fc0e2ce6ec..22552529b9e 100644 --- a/spec/features/merge_requests/user_posts_notes_spec.rb +++ b/spec/features/merge_requests/user_posts_notes_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'Merge requests > User posts notes', :js do + include NoteInteractionHelpers + let(:project) { create(:project) } let(:merge_request) do create(:merge_request, source_project: project, target_project: project) @@ -73,6 +75,8 @@ describe 'Merge requests > User posts notes', :js do describe 'editing the note' do before do find('.note').hover + open_more_actions_dropdown(note) + find('.js-note-edit').click end @@ -98,8 +102,10 @@ describe 'Merge requests > User posts notes', :js do find('.btn-save').click end - wait_for_ajax + wait_for_requests find('.note').hover + open_more_actions_dropdown(note) + find('.js-note-edit').click page.within('.current-note-edit-form') do @@ -126,6 +132,8 @@ describe 'Merge requests > User posts notes', :js do describe 'deleting an attachment' do before do find('.note').hover + open_more_actions_dropdown(note) + find('.js-note-edit').click end @@ -139,7 +147,7 @@ describe 'Merge requests > User posts notes', :js do find('.js-note-attachment-delete').click is_expected.not_to have_css('.note-attachment') is_expected.not_to have_css('.current-note-edit-form') - wait_for_ajax + wait_for_requests end 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 index f0ad57eb92f..0e64a3e1a4b 100644 --- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -21,7 +21,7 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do end after do - wait_for_ajax + wait_for_requests end describe 'toggling the WIP prefix in the title from note' do @@ -160,7 +160,7 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do it 'changes target branch from a note' do write_note("message start \n/target_branch merge-test\n message end.") - wait_for_ajax + wait_for_requests expect(page).not_to have_content('/target_branch') expect(page).to have_content('message start') expect(page).to have_content('message end.') diff --git a/spec/features/merge_requests/versions_spec.rb b/spec/features/merge_requests/versions_spec.rb index 2b5b803946c..aad522ee26e 100644 --- a/spec/features/merge_requests/versions_spec.rb +++ b/spec/features/merge_requests/versions_spec.rb @@ -75,7 +75,7 @@ feature 'Merge Request versions', js: true, feature: true do find(".js-comment-button").click end - wait_for_ajax + wait_for_requests expect(page).to have_content("Typo, please fix") end @@ -124,9 +124,11 @@ feature 'Merge Request versions', js: true, feature: true do diff_refs: merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs ) outdated_diff_note = create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) + outdated_diff_note.position = outdated_diff_note.original_position + outdated_diff_note.save! visit current_url - wait_for_ajax + wait_for_requests expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']") end @@ -144,7 +146,7 @@ feature 'Merge Request versions', js: true, feature: true do find(".js-comment-button").click end - wait_for_ajax + wait_for_requests expect(page).to have_content("Typo, please fix") end diff --git a/spec/features/merge_requests/widget_deployments_spec.rb b/spec/features/merge_requests/widget_deployments_spec.rb index 8370499f6ed..118ecd9cba5 100644 --- a/spec/features/merge_requests/widget_deployments_spec.rb +++ b/spec/features/merge_requests/widget_deployments_spec.rb @@ -18,7 +18,7 @@ feature 'Widget Deployments Header', feature: true, js: true do end scenario 'displays that the environment is deployed' do - wait_for_ajax + wait_for_requests expect(page).to have_content("Deployed to #{environment.name}") expect(find('.js-deploy-time')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) @@ -34,7 +34,7 @@ feature 'Widget Deployments Header', feature: true, js: true do end background do - wait_for_ajax + wait_for_requests end scenario 'does show stop button' do diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index 3fcdc9f2c61..d26e5330e1c 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -27,7 +27,7 @@ describe 'Merge request', :feature, :js do it 'shows widget status after creating new merge request' do click_button 'Submit merge request' - wait_for_ajax + wait_for_requests expect(page).to have_selector('.accept-merge-request') expect(find('.accept-merge-request')['disabled']).not_to be(true) @@ -48,7 +48,7 @@ describe 'Merge request', :feature, :js do end it 'shows environments link' do - wait_for_ajax + wait_for_requests page.within('.mr-widget-heading') do expect(page).to have_content("Deployed to #{environment.name}") @@ -58,7 +58,11 @@ describe 'Merge request', :feature, :js do it 'shows green accept merge request button' do # Wait for the `ci_status` and `merge_check` requests +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_selector('.accept-merge-request') expect(find('.accept-merge-request')['disabled']).not_to be(true) end @@ -76,7 +80,7 @@ describe 'Merge request', :feature, :js do it 'has danger button while waiting for external CI status' do # Wait for the `ci_status` and `merge_check` requests - wait_for_ajax + wait_for_requests expect(page).to have_selector('.accept-merge-request.btn-danger') end end @@ -88,7 +92,8 @@ describe 'Merge request', :feature, :js do sha: merge_request.diff_head_sha, ref: merge_request.source_branch, status: 'failed', - statuses: [commit_status]) + statuses: [commit_status], + head_pipeline_of: merge_request) create(:ci_build, :pending, pipeline: pipeline) visit namespace_project_merge_request_path(project.namespace, project, merge_request) @@ -96,17 +101,20 @@ describe 'Merge request', :feature, :js do it 'has danger button when not succeeded' do # Wait for the `ci_status` and `merge_check` requests - wait_for_ajax + wait_for_requests expect(page).to have_selector('.accept-merge-request.btn-danger') end end context 'when merge request is in the blocked pipeline state' do before do - create(:ci_pipeline, project: project, - sha: merge_request.diff_head_sha, - ref: merge_request.source_branch, - status: :manual) + create( + :ci_pipeline, + project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + status: :manual, + head_pipeline_of: merge_request) visit namespace_project_merge_request_path(project.namespace, project, @@ -128,7 +136,8 @@ describe 'Merge request', :feature, :js do sha: merge_request.diff_head_sha, ref: merge_request.source_branch, status: 'pending', - statuses: [commit_status]) + statuses: [commit_status], + head_pipeline_of: merge_request) create(:ci_build, :pending, pipeline: pipeline) visit namespace_project_merge_request_path(project.namespace, project, merge_request) @@ -136,7 +145,11 @@ describe 'Merge request', :feature, :js do it 'has info button when MWBS button' do # Wait for the `ci_status` and `merge_check` requests +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_selector('.accept-merge-request.btn-info') end end @@ -154,7 +167,11 @@ describe 'Merge request', :feature, :js do it 'shows information about the merge error' do # Wait for the `ci_status` and `merge_check` requests +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 page.within('.mr-widget-body') do expect(page).to have_content('Something went wrong') @@ -175,7 +192,7 @@ describe 'Merge request', :feature, :js do it 'shows information about the merge error' do # Wait for the `ci_status` and `merge_check` requests - wait_for_ajax + wait_for_requests page.within('.mr-widget-body') do expect(page).to have_content('Something went wrong') @@ -197,4 +214,25 @@ describe 'Merge request', :feature, :js do end end end + + context 'user can merge into source project but cannot push to fork', js: true do + let(:fork_project) { create(:project, :public) } + let(:user2) { create(:user) } + + before do + project.team << [user2, :master] + logout + login_as user2 + merge_request.update(target_project: fork_project) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'user can merge into the source project' do + expect(page).to have_button('Merge', disabled: false) + end + + it 'user cannot remove source branch' do + expect(page).to have_field('remove-source-branch-input', disabled: true) + end + end end diff --git a/spec/features/milestones/milestones_spec.rb b/spec/features/milestones/milestones_spec.rb index 9eec3d7f270..b3dfd6d0e81 100644 --- a/spec/features/milestones/milestones_spec.rb +++ b/spec/features/milestones/milestones_spec.rb @@ -78,7 +78,7 @@ describe 'Milestone draggable', feature: true, js: true do scroll_into_view('.milestone-content') drag_to(selector: '.issues-sortable-list', list_to_index: 1) - wait_for_ajax + wait_for_requests end def create_and_drag_merge_request(params = {}) @@ -87,12 +87,12 @@ describe 'Milestone draggable', feature: true, js: true do visit namespace_project_milestone_path(project.namespace, project, milestone) page.find("a[href='#tab-merge-requests']").click - wait_for_ajax + wait_for_requests scroll_into_view('.milestone-content') drag_to(selector: '.merge_requests-sortable-list', list_to_index: 1) - wait_for_ajax + wait_for_requests end def scroll_into_view(selector) diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index e63feb14b7e..7df628fd7a0 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -47,6 +47,21 @@ describe 'Profile account page', feature: true do end end + describe 'when I reset RSS token' do + before do + visit profile_account_path + end + + it 'resets RSS token' do + previous_token = find("#rss-token").value + + click_link('Reset RSS token') + + expect(page).to have_content 'RSS token was successfully reset' + expect(find('#rss-token').value).not_to eq(previous_token) + end + end + describe 'when I reset incoming email token' do before do allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true) diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 27a20e78a43..7e2e685df26 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -17,6 +17,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do def disallow_personal_access_token_saves! allow_any_instance_of(PersonalAccessToken).to receive(:save).and_return(false) + errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") } allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors) end @@ -91,8 +92,11 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do context "when revocation fails" do it "displays an error message" do - disallow_personal_access_token_saves! visit profile_personal_access_tokens_path + allow_any_instance_of(PersonalAccessToken).to receive(:update!).and_return(false) + + errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") } + allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors) click_on "Revoke" expect(active_personal_access_tokens).to have_text(personal_access_token.name) diff --git a/spec/features/profiles/preferences_spec.rb b/spec/features/profiles/preferences_spec.rb index 15c8677fcd3..d368bc4d753 100644 --- a/spec/features/profiles/preferences_spec.rb +++ b/spec/features/profiles/preferences_spec.rb @@ -44,7 +44,7 @@ describe 'Profile > Preferences', feature: true do expect(page.current_path).to eq starred_dashboard_projects_path end - click_link 'Your projects' + find('.shortcuts-activity').trigger('click') expect(page).not_to have_content("You don't have starred projects yet") expect(page.current_path).to eq dashboard_projects_path diff --git a/spec/features/projects/activity/rss_spec.rb b/spec/features/projects/activity/rss_spec.rb index b47c6d431eb..3c1de5c09b2 100644 --- a/spec/features/projects/activity/rss_spec.rb +++ b/spec/features/projects/activity/rss_spec.rb @@ -16,7 +16,7 @@ feature 'Project Activity RSS' do visit path end - it_behaves_like "it has an RSS button with current_user's private token" + it_behaves_like "it has an RSS button with current_user's RSS token" end context 'when signed out' do @@ -24,6 +24,6 @@ feature 'Project Activity RSS' do visit path end - it_behaves_like "it has an RSS button without a private token" + it_behaves_like "it has an RSS button without an RSS token" end end diff --git a/spec/features/projects/artifacts/browse_spec.rb b/spec/features/projects/artifacts/browse_spec.rb new file mode 100644 index 00000000000..68375956273 --- /dev/null +++ b/spec/features/projects/artifacts/browse_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +feature 'Browse artifact', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def browse_path(path) + browse_namespace_project_job_artifacts_path(project.namespace, project, job, path) + end + + context 'when visiting old URL' do + let(:browse_url) do + browse_path('other_artifacts_0.1.2') + end + + before do + visit browse_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(browse_url) + end + end +end diff --git a/spec/features/projects/artifacts/download_spec.rb b/spec/features/projects/artifacts/download_spec.rb new file mode 100644 index 00000000000..dd9454840ee --- /dev/null +++ b/spec/features/projects/artifacts/download_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +feature 'Download artifact', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project, sha: project.commit.sha, ref: 'master') } + let(:job) { create(:ci_build, :artifacts, :success, pipeline: pipeline) } + + shared_examples 'downloading' do + it 'downloads the zip' do + expect(page.response_headers['Content-Disposition']) + .to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"}) + + # Check the content does match, but don't print this as error message + expect(page.source.b == job.artifacts_file.file.read.b) + end + end + + context 'when downloading' do + before do + visit download_url + end + + context 'via job id' do + let(:download_url) do + download_namespace_project_job_artifacts_path(project.namespace, project, job) + end + + it_behaves_like 'downloading' + end + + context 'via branch name and job name' do + let(:download_url) do + latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{pipeline.ref}/download", job: job.name) + end + + it_behaves_like 'downloading' + end + end + + context 'when visiting old URL' do + before do + visit download_url.sub('/-/jobs', '/builds') + end + + context 'via job id' do + let(:download_url) do + download_namespace_project_job_artifacts_path(project.namespace, project, job) + end + + it_behaves_like 'downloading' + end + + context 'via branch name and job name' do + let(:download_url) do + latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{pipeline.ref}/download", job: job.name) + end + + it_behaves_like 'downloading' + end + end +end diff --git a/spec/features/projects/artifacts/file_spec.rb b/spec/features/projects/artifacts/file_spec.rb index 74308a7e8dd..25c4f3c87a2 100644 --- a/spec/features/projects/artifacts/file_spec.rb +++ b/spec/features/projects/artifacts/file_spec.rb @@ -6,14 +6,18 @@ feature 'Artifact file', :js, feature: true do let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } def visit_file(path) - visit file_namespace_project_build_artifacts_path(project.namespace, project, build, path) + visit file_path(path) + end + + def file_path(path) + file_namespace_project_job_artifacts_path(project.namespace, project, build, path) end context 'Text file' do before do visit_file('other_artifacts_0.1.2/doc_sample.txt') - wait_for_ajax + wait_for_requests end it 'displays an error' do @@ -37,7 +41,7 @@ feature 'Artifact file', :js, feature: true do before do visit_file('rails_sample.jpg') - wait_for_ajax + wait_for_requests end it 'displays the blob' do @@ -56,4 +60,18 @@ feature 'Artifact file', :js, feature: true do end end end + + context 'when visiting old URL' do + let(:file_url) do + file_path('other_artifacts_0.1.2/doc_sample.txt') + end + + before do + visit file_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(file_url) + end + end end diff --git a/spec/features/projects/artifacts/raw_spec.rb b/spec/features/projects/artifacts/raw_spec.rb new file mode 100644 index 00000000000..b589701729d --- /dev/null +++ b/spec/features/projects/artifacts/raw_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +feature 'Raw artifact', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def raw_path(path) + raw_namespace_project_job_artifacts_path(project.namespace, project, job, path) + end + + context 'when visiting old URL' do + let(:raw_url) do + raw_path('other_artifacts_0.1.2/doc_sample.txt') + end + + before do + visit raw_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(raw_url) + end + end +end diff --git a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb index d94204230f6..53c5a52ce3a 100644 --- a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb +++ b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb @@ -55,7 +55,7 @@ feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true, end end - describe 'Click "Blame" button' do + describe 'Click "Annotate" button' do it 'works with no initial line number fragment hash' do visit_blob diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 5955623f565..82cfbfda157 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -5,13 +5,13 @@ feature 'File blob', :js, feature: true do def visit_blob(path, fragment = nil) visit namespace_project_blob_path(project.namespace, project, File.join('master', path), anchor: fragment) + + wait_for_requests end context 'Ruby file' do before do visit_blob('files/ruby/popen.rb') - - wait_for_ajax end it 'displays the blob' do @@ -35,8 +35,6 @@ feature 'File blob', :js, feature: true do context 'visiting directly' do before do visit_blob('files/markdown/ruby-style-guide.md') - - wait_for_ajax end it 'displays the blob using the rich viewer' do @@ -63,7 +61,7 @@ feature 'File blob', :js, feature: true do before do find('.js-blob-viewer-switch-btn[data-viewer=simple]').click - wait_for_ajax + wait_for_requests end it 'displays the blob using the simple viewer' do @@ -84,7 +82,7 @@ feature 'File blob', :js, feature: true do before do find('.js-blob-viewer-switch-btn[data-viewer=rich]').click - wait_for_ajax + wait_for_requests end it 'displays the blob using the rich viewer' do @@ -104,8 +102,6 @@ feature 'File blob', :js, feature: true do context 'visiting with a line number anchor' do before do visit_blob('files/markdown/ruby-style-guide.md', 'L1') - - wait_for_ajax end it 'displays the blob using the simple viewer' do @@ -148,8 +144,6 @@ feature 'File blob', :js, feature: true do project.update_attribute(:lfs_enabled, true) visit_blob('files/lfs/file.md') - - wait_for_ajax end it 'displays an error' do @@ -176,7 +170,7 @@ feature 'File blob', :js, feature: true do before do find('.js-blob-viewer-switcher .js-blob-viewer-switch-btn[data-viewer=simple]').click - wait_for_ajax + wait_for_requests end it 'displays an error' do @@ -198,8 +192,6 @@ feature 'File blob', :js, feature: true do context 'when LFS is disabled on the project' do before do visit_blob('files/lfs/file.md') - - wait_for_ajax end it 'displays the blob' do @@ -235,8 +227,6 @@ feature 'File blob', :js, feature: true do ).execute visit_blob('files/test.pdf') - - wait_for_ajax end it 'displays the blob' do @@ -263,8 +253,6 @@ feature 'File blob', :js, feature: true do project.update_attribute(:lfs_enabled, true) visit_blob('files/lfs/lfs_object.iso') - - wait_for_ajax end it 'displays the blob' do @@ -287,8 +275,6 @@ feature 'File blob', :js, feature: true do context 'when LFS is disabled on the project' do before do visit_blob('files/lfs/lfs_object.iso') - - wait_for_ajax end it 'displays the blob' do @@ -312,8 +298,6 @@ feature 'File blob', :js, feature: true do context 'ZIP file' do before do visit_blob('Gemfile.zip') - - wait_for_ajax end it 'displays the blob' do @@ -348,8 +332,6 @@ feature 'File blob', :js, feature: true do ).execute visit_blob('files/empty.md') - - wait_for_ajax end it 'displays an error' do @@ -369,4 +351,116 @@ feature 'File blob', :js, feature: true do end end end + + context '.gitlab-ci.yml' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add .gitlab-ci.yml", + file_path: '.gitlab-ci.yml', + file_content: File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) + ).execute + + visit_blob('.gitlab-ci.yml') + end + + it 'displays an auxiliary viewer' do + aggregate_failures do + # shows that configuration is valid + expect(page).to have_content('This GitLab CI configuration is valid.') + + # shows a learn more link + expect(page).to have_link('Learn more') + end + end + end + + context '.gitlab/route-map.yml' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add .gitlab/route-map.yml", + file_path: '.gitlab/route-map.yml', + file_content: <<-MAP.strip_heredoc + # Team data + - source: 'data/team.yml' + public: 'team/' + MAP + ).execute + + visit_blob('.gitlab/route-map.yml') + end + + it 'displays an auxiliary viewer' do + aggregate_failures do + # shows that map is valid + expect(page).to have_content('This Route Map is valid.') + + # shows a learn more link + expect(page).to have_link('Learn more') + end + end + end + + context 'LICENSE' do + before do + visit_blob('LICENSE') + end + + it 'displays an auxiliary viewer' do + aggregate_failures do + # shows license + expect(page).to have_content('This project is licensed under the MIT License.') + + # shows a learn more link + expect(page).to have_link('Learn more', 'http://choosealicense.com/licenses/mit/') + end + end + end + + context '*.gemspec' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add activerecord.gemspec", + file_path: 'activerecord.gemspec', + file_content: <<-SPEC.strip_heredoc + Gem::Specification.new do |s| + s.platform = Gem::Platform::RUBY + s.name = "activerecord" + end + SPEC + ).execute + + visit_blob('activerecord.gemspec') + end + + it 'displays an auxiliary viewer' do + aggregate_failures do + # shows names of dependency manager and package + expect(page).to have_content('This project manages its dependencies using RubyGems and defines a gem named activerecord.') + + # shows a link to the gem + expect(page).to have_link('activerecord', 'https://rubygems.org/gems/activerecord') + + # shows a learn more link + expect(page).to have_link('Learn more', 'http://choosealicense.com/licenses/mit/') + end + end + end end diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb index cc5b1a7e734..1a38997450d 100644 --- a/spec/features/projects/blobs/edit_spec.rb +++ b/spec/features/projects/blobs/edit_spec.rb @@ -18,7 +18,7 @@ feature 'Editing file blob', feature: true, js: true do end def edit_and_commit - wait_for_ajax + wait_for_requests find('.js-edit-blob').click execute_script('ace.edit("editor").setValue("class NextFeature\nend\n")') click_button 'Commit changes' diff --git a/spec/features/projects/blobs/user_create_spec.rb b/spec/features/projects/blobs/user_create_spec.rb index d805450e095..4b6c55f5f44 100644 --- a/spec/features/projects/blobs/user_create_spec.rb +++ b/spec/features/projects/blobs/user_create_spec.rb @@ -15,7 +15,7 @@ feature 'New blob creation', feature: true, js: true do end def edit_file - wait_for_ajax + wait_for_requests fill_in 'file_name', with: 'feature.rb' execute_script("ace.edit('editor').setValue('#{content}')") end diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb index 8e0306ce83b..7668ce5f8be 100644 --- a/spec/features/projects/branches_spec.rb +++ b/spec/features/projects/branches_spec.rb @@ -4,7 +4,13 @@ describe 'Branches', feature: true do let(:project) { create(:project, :public) } let(:repository) { project.repository } - context 'logged in' do + def set_protected_branch_name(branch_name) + find(".js-protected-branch-select").click + find(".dropdown-input-field").set(branch_name) + click_on("Create wildcard #{branch_name}") + end + + context 'logged in as developer' do before do login_as :user project.team << [@user, :developer] @@ -38,6 +44,83 @@ describe 'Branches', feature: true do expect(find('.all-branches')).to have_selector('li', count: 1) end end + + describe 'Delete unprotected branch' do + it 'removes branch after confirmation', js: true do + visit namespace_project_branches_path(project.namespace, project) + + 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) + find('.js-branch-fix .btn-remove').trigger(:click) + + expect(page).not_to have_content('fix') + expect(find('.all-branches')).to have_selector('li', count: 0) + end + end + + describe 'Delete protected branch' do + before do + project.add_user(@user, :master) + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('fix') + click_on "Protect" + + within(".protected-branches-list") { expect(page).to have_content('fix') } + expect(ProtectedBranch.count).to eq(1) + project.add_user(@user, :developer) + end + + it 'does not allow devleoper to removes protected branch', js: true do + visit namespace_project_branches_path(project.namespace, project) + + fill_in 'branch-search', with: 'fix' + find('#branch-search').native.send_keys(:enter) + + expect(page).to have_css('.btn-remove.disabled') + end + end + end + + context 'logged in as master' do + before do + login_as :user + project.team << [@user, :master] + end + + describe 'Delete protected branch' do + before do + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('fix') + click_on "Protect" + + within(".protected-branches-list") { expect(page).to have_content('fix') } + expect(ProtectedBranch.count).to eq(1) + end + + it 'removes branch after modal confirmation', js: true do + visit namespace_project_branches_path(project.namespace, project) + + 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) + page.find('[data-target="#modal-delete-branch"]').trigger(:click) + + expect(page).to have_css('.js-delete-branch[disabled]') + fill_in 'delete_branch_input', with: 'fix' + click_link 'Delete protected branch' + + fill_in 'branch-search', with: 'fix' + find('#branch-search').native.send_keys(:enter) + + expect(page).to have_content('No branches to show') + end + end end context 'logged out' do diff --git a/spec/features/projects/commit/cherry_pick_spec.rb b/spec/features/projects/commit/cherry_pick_spec.rb index fa67d390c47..bc7ca0ddd38 100644 --- a/spec/features/projects/commit/cherry_pick_spec.rb +++ b/spec/features/projects/commit/cherry_pick_spec.rb @@ -72,11 +72,11 @@ describe 'Cherry-pick Commits' do click_button 'master' end - wait_for_ajax + wait_for_requests page.within('#modal-cherry-pick-commit .dropdown-menu') do find('.dropdown-input input').set('feature') - wait_for_ajax + wait_for_requests click_link "feature" end diff --git a/spec/features/projects/commit/mini_pipeline_graph_spec.rb b/spec/features/projects/commit/mini_pipeline_graph_spec.rb index 98c0f2c63b0..f2de195eb7f 100644 --- a/spec/features/projects/commit/mini_pipeline_graph_spec.rb +++ b/spec/features/projects/commit/mini_pipeline_graph_spec.rb @@ -32,7 +32,7 @@ feature 'Mini Pipeline Graph in Commit View', :js, :feature do it 'should show the builds list when stage is clicked' do first('.mini-pipeline-graph-dropdown-toggle').click - wait_for_ajax + wait_for_requests page.within '.js-builds-dropdown-list' do expect(page).to have_selector('.ci-status-icon-running') diff --git a/spec/features/projects/commit/rss_spec.rb b/spec/features/projects/commit/rss_spec.rb index 6e0e1916f87..03b6d560c96 100644 --- a/spec/features/projects/commit/rss_spec.rb +++ b/spec/features/projects/commit/rss_spec.rb @@ -12,8 +12,8 @@ feature 'Project Commits RSS' do visit path end - it_behaves_like "it has an RSS button with current_user's private token" - it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + it_behaves_like "it has an RSS button with current_user's RSS token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end context 'when signed out' do @@ -21,7 +21,7 @@ feature 'Project Commits RSS' do visit path end - it_behaves_like "it has an RSS button without a private token" - it_behaves_like "an autodiscoverable RSS feed without a private token" + it_behaves_like "it has an RSS button without an RSS token" + it_behaves_like "an autodiscoverable RSS feed without an RSS token" end end diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb index b2a3b111c9e..ee6985ad993 100644 --- a/spec/features/projects/compare_spec.rb +++ b/spec/features/projects/compare_spec.rb @@ -24,6 +24,7 @@ describe "Compare", js: true do expect(find(".js-compare-to-dropdown .dropdown-toggle-text")).to have_content("binary-encoding") click_button "Compare" + expect(page).to have_content "Commits" end @@ -52,8 +53,12 @@ describe "Compare", js: true do def select_using_dropdown(dropdown_type, selection) dropdown = find(".js-compare-#{dropdown_type}-dropdown") dropdown.find(".compare-dropdown-toggle").click + # find input before using to wait for the inputs visiblity + dropdown.find('.dropdown-menu') dropdown.fill_in("Filter by Git revision", with: selection) - wait_for_ajax - dropdown.find_all("a[data-ref=\"#{selection}\"]", visible: true).last.click + wait_for_requests + # find before all to wait for the items visiblity + dropdown.find("a[data-ref=\"#{selection}\"]", match: :first) + dropdown.all("a[data-ref=\"#{selection}\"]").last.click end end diff --git a/spec/features/projects/developer_views_empty_project_instructions_spec.rb b/spec/features/projects/developer_views_empty_project_instructions_spec.rb index 2352329d58c..0c51fe72ca4 100644 --- a/spec/features/projects/developer_views_empty_project_instructions_spec.rb +++ b/spec/features/projects/developer_views_empty_project_instructions_spec.rb @@ -56,14 +56,8 @@ feature 'Developer views empty project instructions', feature: true do end def expect_instructions_for(protocol) - url = - case protocol - when 'ssh' - project.ssh_url_to_repo - when 'http' - project.http_url_to_repo(developer) - end - - expect(page).to have_content("git clone #{url}") + msg = :"#{protocol.downcase}_url_to_repo" + + expect(page).to have_content("git clone #{project.send(msg)}") end end diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 86ce50c976f..18b608c863e 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -12,6 +12,7 @@ feature 'Environment', :feature do feature 'environment details page' do given!(:environment) { create(:environment, project: project) } + given!(:permissions) { } given!(:deployment) { } given!(:action) { } @@ -62,20 +63,31 @@ feature 'Environment', :feature do name: 'deploy to production') end - given(:role) { :master } + context 'when user has ability to trigger deployment' do + given(:permissions) do + create(:protected_branch, :developers_can_merge, + name: action.ref, project: project) + end - scenario 'does show a play button' do - expect(page).to have_link(action.name.humanize) - end + it 'does show a play button' do + expect(page).to have_link(action.name.humanize) + end + + it 'does allow to play manual action' do + expect(action).to be_manual - scenario 'does allow to play manual action' do - expect(action).to be_manual + expect { click_link(action.name.humanize) } + .not_to change { Ci::Pipeline.count } - expect { click_link(action.name.humanize) } - .not_to change { Ci::Pipeline.count } + expect(page).to have_content(action.name) + expect(action.reload).to be_pending + end + end - expect(page).to have_content(action.name) - expect(action.reload).to be_pending + context 'when user has no ability to trigger a deployment' do + it 'does not show a play button' do + expect(page).not_to have_link(action.name.humanize) + end end context 'with external_url' do @@ -134,12 +146,23 @@ feature 'Environment', :feature do on_stop: 'close_app') end - given(:role) { :master } + context 'when user has ability to stop environment' do + given(:permissions) do + create(:protected_branch, :developers_can_merge, + name: action.ref, project: project) + end - scenario 'does allow to stop environment' do - click_link('Stop') + it 'allows to stop environment' do + click_link('Stop') - expect(page).to have_content('close_app') + expect(page).to have_content('close_app') + end + end + + context 'when user has no ability to stop environment' do + it 'does not allow to stop environment' do + expect(page).to have_no_link('Stop') + end end context 'for reporter' do @@ -150,12 +173,6 @@ feature 'Environment', :feature do end end end - - context 'without stop action' do - scenario 'does allow to stop environment' do - click_link('Stop') - end - end end context 'when environment is stopped' do diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index cf393afccbb..613b1edba36 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -31,7 +31,7 @@ feature 'Environments page', :feature, :js do it 'should show one environment' do visit namespace_project_environments_path(project.namespace, project, scope: 'available') expect(page).to have_css('.environments-container') - expect(page.all('tbody > tr').length).to eq(1) + expect(page.all('.environment-name').length).to eq(1) end end @@ -59,7 +59,7 @@ feature 'Environments page', :feature, :js do it 'should show one environment' do visit namespace_project_environments_path(project.namespace, project, scope: 'stopped') expect(page).to have_css('.environments-container') - expect(page.all('tbody > tr').length).to eq(1) + expect(page.all('.environment-name').length).to eq(1) end end end @@ -239,7 +239,9 @@ feature 'Environments page', :feature, :js do context 'when logged as developer' do before do - click_link 'New environment' + within(".top-area") do + click_link 'New environment' + end end context 'for valid name' do diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index e1781cf320a..c49648f54bd 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -21,17 +21,17 @@ describe 'Edit Project Settings', feature: true do select 'Disabled', from: "project_project_feature_attributes_#{tool_name}_access_level" click_button 'Save changes' - wait_for_ajax + wait_for_requests 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 + wait_for_requests 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 + wait_for_requests expect(page).to have_selector(".shortcuts-#{shortcut_name}") sleep 0.1 @@ -74,7 +74,7 @@ describe 'Edit Project Settings', feature: true do 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), + merge_requests: namespace_project_merge_requests_path(project.namespace, project) } end @@ -169,7 +169,7 @@ describe 'Edit Project Settings', feature: true do select "Disabled", from: "project_project_feature_attributes_wiki_access_level" click_button "Save changes" - wait_for_ajax + wait_for_requests visit namespace_project_path(project.namespace, project) @@ -182,7 +182,7 @@ describe 'Edit Project Settings', feature: true do select "Disabled", from: "project_project_feature_attributes_wiki_access_level" click_button "Save changes" - wait_for_ajax + wait_for_requests visit activity_namespace_project_path(project.namespace, project) @@ -223,7 +223,7 @@ describe 'Edit Project Settings', feature: true do def save_changes_and_check_activity_tab click_button "Save changes" - wait_for_ajax + wait_for_requests visit activity_namespace_project_path(project.namespace, project) diff --git a/spec/features/projects/files/browse_files_spec.rb b/spec/features/projects/files/browse_files_spec.rb index 70e96efd557..30a1eedbb48 100644 --- a/spec/features/projects/files/browse_files_spec.rb +++ b/spec/features/projects/files/browse_files_spec.rb @@ -12,7 +12,7 @@ feature 'user browses project', feature: true, js: true do scenario "can see blame of '.gitignore'" do click_link ".gitignore" - click_link 'Blame' + click_link 'Annotate' expect(page).to have_content "*.rb" expect(page).to have_content "Dmitriy Zaporozhets" @@ -24,11 +24,23 @@ feature 'user browses project', feature: true, js: true do click_link 'files' click_link 'lfs' click_link 'lfs_object.iso' - wait_for_ajax + wait_for_requests expect(page).not_to have_content 'Download (1.5 MB)' expect(page).to have_content 'version https://git-lfs.github.com/spec/v1' expect(page).to have_content 'oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897' expect(page).to have_content 'size 1575078' end + + scenario 'can see last commit for current directory' do + last_commit = project.repository.last_commit_for_path(project.default_branch, 'files') + + click_link 'files' + wait_for_requests + + page.within('.blob-commit-info') do + expect(page).to have_content last_commit.short_id + expect(page).to have_content last_commit.author_name + end + end end diff --git a/spec/features/projects/files/dockerfile_dropdown_spec.rb b/spec/features/projects/files/dockerfile_dropdown_spec.rb index 548131c7cd4..93909e91d05 100644 --- a/spec/features/projects/files/dockerfile_dropdown_spec.rb +++ b/spec/features/projects/files/dockerfile_dropdown_spec.rb @@ -19,14 +19,14 @@ feature 'User wants to add a Dockerfile file', feature: true do scenario 'user can pick a Dockerfile file from the dropdown', js: true do find('.js-dockerfile-selector').click - wait_for_ajax + wait_for_requests within '.dockerfile-selector' do find('.dropdown-input-field').set('HTTPd') find('.dropdown-content li', text: 'HTTPd').click end - wait_for_ajax + wait_for_requests expect(page).to have_css('.dockerfile-selector .dropdown-toggle-text', text: 'HTTPd') expect(page).to have_content('COPY ./ /usr/local/apache2/htdocs/') diff --git a/spec/features/projects/files/find_file_keyboard_spec.rb b/spec/features/projects/files/find_file_keyboard_spec.rb index e7a6749d8ac..ee42bcaec4b 100644 --- a/spec/features/projects/files/find_file_keyboard_spec.rb +++ b/spec/features/projects/files/find_file_keyboard_spec.rb @@ -10,7 +10,7 @@ feature 'Find file keyboard shortcuts', feature: true, js: true do visit namespace_project_find_file_path(project.namespace, project, project.repository.root_ref) - wait_for_ajax + wait_for_requests end it 'opens file when pressing enter key' do diff --git a/spec/features/projects/files/gitignore_dropdown_spec.rb b/spec/features/projects/files/gitignore_dropdown_spec.rb index e59428f8b24..e9f49453121 100644 --- a/spec/features/projects/files/gitignore_dropdown_spec.rb +++ b/spec/features/projects/files/gitignore_dropdown_spec.rb @@ -15,12 +15,12 @@ feature 'User wants to add a .gitignore file', feature: true do scenario 'user can pick a .gitignore file from the dropdown', js: true do find('.js-gitignore-selector').click - wait_for_ajax + wait_for_requests within '.gitignore-selector' do find('.dropdown-input-field').set('rails') find('.dropdown-content li', text: 'Rails').click end - wait_for_ajax + wait_for_requests expect(page).to have_css('.gitignore-selector .dropdown-toggle-text', text: 'Rails') expect(page).to have_content('/.bundle') diff --git a/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb b/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb index 85b66b93fba..031b89d0499 100644 --- a/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb +++ b/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb @@ -15,12 +15,12 @@ feature 'User wants to add a .gitlab-ci.yml file', feature: true do scenario 'user can pick a template from the dropdown', js: true do find('.js-gitlab-ci-yml-selector').click - wait_for_ajax + wait_for_requests within '.gitlab-ci-yml-selector' do find('.dropdown-input-field').set('Jekyll') find('.dropdown-content li', text: 'Jekyll').click end - wait_for_ajax + wait_for_requests expect(page).to have_css('.gitlab-ci-yml-selector .dropdown-toggle-text', text: 'Jekyll') expect(page).to have_content('This file is a template, and might need editing before it works on your project') 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 249830921ac..8d410cc3f2e 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 @@ -63,7 +63,7 @@ feature 'project owner creates a license file', feature: true, js: true do page.within('.js-license-selector-wrap') do click_button 'Apply a license template' click_link template - wait_for_ajax + wait_for_requests end end end 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 70a41886985..8e197bccabf 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 @@ -40,7 +40,7 @@ feature 'project owner sees a link to create a license file in empty project', f page.within('.js-license-selector-wrap') do click_button 'Apply a license template' click_link template - wait_for_ajax + wait_for_requests end end end diff --git a/spec/features/projects/files/undo_template_spec.rb b/spec/features/projects/files/undo_template_spec.rb index cd3af0b7d29..de10eec0557 100644 --- a/spec/features/projects/files/undo_template_spec.rb +++ b/spec/features/projects/files/undo_template_spec.rb @@ -57,7 +57,7 @@ end def select_file_template(template_selector_selector, template_name) find(template_selector_selector).click find('.dropdown-content li', text: template_name).click - wait_for_ajax + wait_for_requests end def select_file_template_type(template_type) diff --git a/spec/features/projects/gfm_autocomplete_load_spec.rb b/spec/features/projects/gfm_autocomplete_load_spec.rb index dd9622f16a0..67bc9142356 100644 --- a/spec/features/projects/gfm_autocomplete_load_spec.rb +++ b/spec/features/projects/gfm_autocomplete_load_spec.rb @@ -10,7 +10,7 @@ describe 'GFM autocomplete loading', feature: true, js: true do end it 'does not load on project#show' do - expect(evaluate_script('gl.GfmAutoComplete.dataSources')).to eq({}) + expect(evaluate_script('gl.GfmAutoComplete')).to eq(nil) end it 'loads on new issue page' do diff --git a/spec/features/projects/guest_navigation_menu_spec.rb b/spec/features/projects/guest_navigation_menu_spec.rb index 726469daba4..b91c3eff478 100644 --- a/spec/features/projects/guest_navigation_menu_spec.rb +++ b/spec/features/projects/guest_navigation_menu_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe "Guest navigation menu" do +describe 'Guest navigation menu' do let(:project) { create(:empty_project, :private, public_builds: false) } let(:guest) { create(:user) } @@ -10,10 +10,10 @@ describe "Guest navigation menu" do login_as(guest) end - it "shows allowed tabs only" do + it 'shows allowed tabs only' do visit namespace_project_path(project.namespace, project) - within(".nav-links") do + within('.layout-nav') do expect(page).to have_content 'Project' expect(page).to have_content 'Issues' expect(page).to have_content 'Wiki' @@ -23,4 +23,60 @@ describe "Guest navigation menu" do expect(page).not_to have_content 'Merge Requests' end end + + it 'does not show fork button' do + visit namespace_project_path(project.namespace, project) + + within('.count-buttons') do + expect(page).not_to have_link 'Fork' + end + end + + it 'does not show clone path' do + visit namespace_project_path(project.namespace, project) + + within('.project-repo-buttons') do + expect(page).not_to have_selector '.project-clone-holder' + end + end + + describe 'project landing page' do + before do + project.project_feature.update!( + issues_access_level: ProjectFeature::DISABLED, + wiki_access_level: ProjectFeature::DISABLED + ) + end + + it 'does not show the project file list landing page' do + visit namespace_project_path(project.namespace, project) + + expect(page).not_to have_selector '.project-stats' + expect(page).not_to have_selector '.project-last-commit' + expect(page).not_to have_selector '.project-show-files' + expect(page).to have_selector '.project-show-customize_workflow' + end + + it 'shows the customize workflow when issues and wiki are disabled' do + visit namespace_project_path(project.namespace, project) + + expect(page).to have_selector '.project-show-customize_workflow' + end + + it 'shows the wiki when enabled' do + project.project_feature.update!(wiki_access_level: ProjectFeature::PRIVATE) + + visit namespace_project_path(project.namespace, project) + + expect(page).to have_selector '.project-show-wiki' + end + + it 'shows the issues when enabled' do + project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE) + + visit namespace_project_path(project.namespace, project) + + expect(page).to have_selector '.issues-list' + end + end end diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb index fa5e30075e3..3076c863dcb 100644 --- a/spec/features/projects/issuable_templates_spec.rb +++ b/spec/features/projects/issuable_templates_spec.rb @@ -34,14 +34,14 @@ feature 'issuable templates', feature: true, js: true do scenario 'user selects "bug" template' do select_template 'bug' - wait_for_ajax + wait_for_requests assert_template save_changes end scenario 'user selects "bug" template and then "no template"' do select_template 'bug' - wait_for_ajax + wait_for_requests select_option 'No template' assert_template('') save_changes('') @@ -49,7 +49,7 @@ feature 'issuable templates', feature: true, js: true do scenario 'user selects "bug" template, edits description and then selects "reset template"' do select_template 'bug' - wait_for_ajax + wait_for_requests find_field('issue_description').send_keys(description_addition) assert_template(template_content + description_addition) select_option 'Reset template' @@ -61,7 +61,7 @@ feature 'issuable templates', feature: true, js: true do start_height = page.evaluate_script('$(".markdown-area").outerHeight()') select_template 'test' - wait_for_ajax + wait_for_requests end_height = page.evaluate_script('$(".markdown-area").outerHeight()') @@ -88,7 +88,7 @@ feature 'issuable templates', feature: true, js: true do scenario 'user selects "bug" template' do select_template 'bug' - wait_for_ajax + wait_for_requests assert_template("#{template_content}") save_changes end @@ -111,7 +111,7 @@ feature 'issuable templates', feature: true, js: true do scenario 'user selects "feature-proposal" template' do select_template 'feature-proposal' - wait_for_ajax + wait_for_requests assert_template save_changes end @@ -143,7 +143,7 @@ feature 'issuable templates', feature: true, js: true do context 'template exists in target project' do scenario 'user selects template' do select_template 'feature-proposal' - wait_for_ajax + wait_for_requests assert_template save_changes end diff --git a/spec/features/projects/issues/rss_spec.rb b/spec/features/projects/issues/rss_spec.rb index 71429f00095..f6852192aef 100644 --- a/spec/features/projects/issues/rss_spec.rb +++ b/spec/features/projects/issues/rss_spec.rb @@ -16,8 +16,8 @@ feature 'Project Issues RSS' do visit path end - it_behaves_like "it has an RSS button with current_user's private token" - it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + it_behaves_like "it has an RSS button with current_user's RSS token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end context 'when signed out' do @@ -25,7 +25,7 @@ feature 'Project Issues RSS' do visit path end - it_behaves_like "it has an RSS button without a private token" - it_behaves_like "an autodiscoverable RSS feed without a private token" + it_behaves_like "it has an RSS button without an RSS token" + it_behaves_like "an autodiscoverable RSS feed without an RSS token" end end diff --git a/spec/features/projects/builds_spec.rb b/spec/features/projects/jobs_spec.rb index ab10434e10c..0eda46649db 100644 --- a/spec/features/projects/builds_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require 'tempfile' -feature 'Builds', :feature do +feature 'Jobs', :feature do let(:user) { create(:user) } let(:user_access_level) { :developer } let(:project) { create(:project) } @@ -19,12 +19,12 @@ feature 'Builds', :feature do login_as(user) end - describe "GET /:project/builds" do + describe "GET /:project/jobs" do let!(:build) { create(:ci_build, pipeline: pipeline) } context "Pending scope" do before do - visit namespace_project_builds_path(project.namespace, project, scope: :pending) + visit namespace_project_jobs_path(project.namespace, project, scope: :pending) end it "shows Pending tab jobs" do @@ -39,7 +39,7 @@ feature 'Builds', :feature do context "Running scope" do before do build.run! - visit namespace_project_builds_path(project.namespace, project, scope: :running) + visit namespace_project_jobs_path(project.namespace, project, scope: :running) end it "shows Running tab jobs" do @@ -54,7 +54,7 @@ feature 'Builds', :feature do context "Finished scope" do before do build.run! - visit namespace_project_builds_path(project.namespace, project, scope: :finished) + visit namespace_project_jobs_path(project.namespace, project, scope: :finished) end it "shows Finished tab jobs" do @@ -67,7 +67,7 @@ feature 'Builds', :feature do context "All jobs" do before do project.builds.running_or_pending.each(&:success) - visit namespace_project_builds_path(project.namespace, project) + visit namespace_project_jobs_path(project.namespace, project) end it "shows All tab jobs" do @@ -78,12 +78,26 @@ feature 'Builds', :feature do expect(page).not_to have_link 'Cancel running' end end + + context "when visiting old URL" do + let(:jobs_url) do + namespace_project_jobs_path(project.namespace, project) + end + + before do + visit jobs_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(jobs_url) + end + end end - describe "POST /:project/builds/:id/cancel_all" do + describe "POST /:project/jobs/:id/cancel_all" do before do build.run! - visit namespace_project_builds_path(project.namespace, project) + visit namespace_project_jobs_path(project.namespace, project) click_link "Cancel running" end @@ -97,10 +111,10 @@ feature 'Builds', :feature do end end - describe "GET /:project/builds/:id" do + describe "GET /:project/jobs/:id" do context "Job from project" do before do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'shows commit`s data' do @@ -117,7 +131,7 @@ feature 'Builds', :feature do context "Job from other project" do before do - visit namespace_project_build_path(project.namespace, project, build2) + visit namespace_project_job_path(project.namespace, project, build2) end it { expect(page.status_code).to eq(404) } @@ -126,7 +140,7 @@ feature 'Builds', :feature do context "Download artifacts" do before do build.update_attributes(artifacts_file: artifacts_file) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'has button to download artifacts' do @@ -139,7 +153,7 @@ feature 'Builds', :feature do build.update_attributes(artifacts_file: artifacts_file, artifacts_expire_at: expire_at) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end context 'no expire date defined' do @@ -183,14 +197,29 @@ feature 'Builds', :feature do end end + context "when visiting old URL" do + let(:job_url) do + namespace_project_job_path(project.namespace, project, build) + end + + before do + visit job_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(job_url) + end + end + feature 'Raw trace' do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + + visit namespace_project_job_path(project.namespace, project, build) end it do - expect(page).to have_link 'Raw' + expect(page).to have_css('.js-raw-link') end end @@ -198,7 +227,7 @@ feature 'Builds', :feature do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end context 'when job has an initial trace' do @@ -222,7 +251,7 @@ feature 'Builds', :feature do end before do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'shows variable key and value after click', js: true do @@ -247,17 +276,17 @@ feature 'Builds', :feature do let(:build) { create(:ci_build, :success, environment: environment.name, deployments: [deployment], pipeline: pipeline) } it 'shows a link for the job' do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) expect(page).to have_link environment.name end end - context 'job is complete and not successfull' do + context 'job is complete and not successful' do let(:build) { create(:ci_build, :failed, environment: environment.name, pipeline: pipeline) } it 'shows a link for the job' do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) expect(page).to have_link environment.name end @@ -268,7 +297,7 @@ feature 'Builds', :feature do let(:build) { create(:ci_build, :success, environment: environment.name, pipeline: pipeline) } it 'shows a link to latest deployment' do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) expect(page).to have_link('latest deployment') end @@ -276,11 +305,11 @@ feature 'Builds', :feature do end end - describe "POST /:project/builds/:id/cancel" do + describe "POST /:project/jobs/:id/cancel" do context "Job from project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link "Cancel" end @@ -294,19 +323,19 @@ feature 'Builds', :feature do context "Job from other project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) - page.driver.post(cancel_namespace_project_build_path(project.namespace, project, build2)) + visit namespace_project_job_path(project.namespace, project, build) + page.driver.post(cancel_namespace_project_job_path(project.namespace, project, build2)) end it { expect(page.status_code).to eq(404) } end end - describe "POST /:project/builds/:id/retry" do + describe "POST /:project/jobs/:id/retry" do context "Job from project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link 'Cancel' page.within('.build-header') do click_link 'Retry job' @@ -322,18 +351,18 @@ feature 'Builds', :feature do end end - context "Build from other project" do + context "Job from other project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link 'Cancel' - page.driver.post(retry_namespace_project_build_path(project.namespace, project, build2)) + page.driver.post(retry_namespace_project_job_path(project.namespace, project, build2)) end it { expect(page).to have_http_status(404) } end - context "Build that current user is not allowed to retry" do + context "Job that current user is not allowed to retry" do before do build.run! build.cancel! @@ -341,7 +370,7 @@ feature 'Builds', :feature do logout_direct login_with(create(:user)) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'does not show the Retry button' do @@ -352,31 +381,31 @@ feature 'Builds', :feature do end end - describe "GET /:project/builds/:id/download" do + describe "GET /:project/jobs/:id/download" do before do build.update_attributes(artifacts_file: artifacts_file) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link 'Download' end context "Build from other project" do before do build2.update_attributes(artifacts_file: artifacts_file) - visit download_namespace_project_build_artifacts_path(project.namespace, project, build2) + visit download_namespace_project_job_artifacts_path(project.namespace, project, build2) end it { expect(page.status_code).to eq(404) } end end - describe 'GET /:project/builds/:id/raw' do + describe 'GET /:project/jobs/:id/raw', :js do context 'access source' do - context 'build from project' do + context 'job from project' do before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } build.run! - visit namespace_project_build_path(project.namespace, project, build) - page.within('.js-build-sidebar') { click_link 'Raw' } + visit namespace_project_job_path(project.namespace, project, build) + find('.js-raw-link-controller').click() end it 'sends the right headers' do @@ -386,11 +415,11 @@ feature 'Builds', :feature do end end - context 'build from other project' do + context 'job from other project' do before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } build2.run! - visit raw_namespace_project_build_path(project.namespace, project, build2) + visit raw_namespace_project_job_path(project.namespace, project, build2) end it 'sends the right headers' do @@ -403,23 +432,23 @@ feature 'Builds', :feature do let(:existing_file) { Tempfile.new('existing-trace-file').path } before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } build.run! allow_any_instance_of(Gitlab::Ci::Trace).to receive(:paths) .and_return(paths) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end - context 'when build has trace in file' do + context 'when build has trace in file', :js do let(:paths) do [existing_file] end before do - page.within('.js-build-sidebar') { click_link 'Raw' } + find('.js-raw-link-controller').click() end it 'sends the right headers' do @@ -429,46 +458,60 @@ feature 'Builds', :feature do end end - context 'when build has trace in DB' do + context 'when job has trace in DB' do let(:paths) { [] } it 'sends the right headers' do - expect(page.status_code).not_to have_link('Raw') + expect(page.status_code).not_to have_selector('.js-raw-link-controller') end end end + + context "when visiting old URL" do + let(:raw_job_url) do + raw_namespace_project_job_path(project.namespace, project, build) + end + + before do + visit raw_job_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(raw_job_url) + end + end end - describe "GET /:project/builds/:id/trace.json" do - context "Build from project" do + describe "GET /:project/jobs/:id/trace.json" do + context "Job from project" do before do - visit trace_namespace_project_build_path(project.namespace, project, build, format: :json) + visit trace_namespace_project_job_path(project.namespace, project, build, format: :json) end it { expect(page.status_code).to eq(200) } end - context "Build from other project" do + context "Job from other project" do before do - visit trace_namespace_project_build_path(project.namespace, project, build2, format: :json) + visit trace_namespace_project_job_path(project.namespace, project, build2, format: :json) end it { expect(page.status_code).to eq(404) } end end - describe "GET /:project/builds/:id/status" do - context "Build from project" do + describe "GET /:project/jobs/:id/status" do + context "Job from project" do before do - visit status_namespace_project_build_path(project.namespace, project, build) + visit status_namespace_project_job_path(project.namespace, project, build) end it { expect(page.status_code).to eq(200) } end - context "Build from other project" do + context "Job from other project" do before do - visit status_namespace_project_build_path(project.namespace, project, build2) + visit status_namespace_project_job_path(project.namespace, project, build2) end it { expect(page.status_code).to eq(404) } diff --git a/spec/features/projects/labels/update_prioritization_spec.rb b/spec/features/projects/labels/update_prioritization_spec.rb index 836f81fb16d..34fafe072a3 100644 --- a/spec/features/projects/labels/update_prioritization_spec.rb +++ b/spec/features/projects/labels/update_prioritization_spec.rb @@ -24,7 +24,7 @@ feature 'Prioritize labels', feature: true do page.within('.other-labels') do all('.js-toggle-priority')[1].click - wait_for_ajax + wait_for_requests expect(page).not_to have_content('feature') end @@ -43,7 +43,7 @@ feature 'Prioritize labels', feature: true do expect(page).to have_content('feature') first('.js-toggle-priority').click - wait_for_ajax + wait_for_requests expect(page).not_to have_content('bug') end @@ -59,7 +59,7 @@ feature 'Prioritize labels', feature: true do page.within('.other-labels') do first('.js-toggle-priority').click - wait_for_ajax + wait_for_requests expect(page).not_to have_content('bug') end @@ -78,7 +78,7 @@ feature 'Prioritize labels', feature: true do expect(page).to have_content('bug') first('.js-toggle-priority').click - wait_for_ajax + wait_for_requests expect(page).not_to have_content('bug') end @@ -107,7 +107,7 @@ feature 'Prioritize labels', feature: true do end refresh - wait_for_ajax + wait_for_requests page.within('.prioritized-labels') do expect(first('li')).to have_content('feature') diff --git a/spec/features/projects/main/rss_spec.rb b/spec/features/projects/main/rss_spec.rb index b1a3af612a1..53966229a2a 100644 --- a/spec/features/projects/main/rss_spec.rb +++ b/spec/features/projects/main/rss_spec.rb @@ -12,7 +12,7 @@ feature 'Project RSS' do visit path end - it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end context 'when signed out' do @@ -20,6 +20,6 @@ feature 'Project RSS' do visit path end - it_behaves_like "an autodiscoverable RSS feed without a private token" + it_behaves_like "an autodiscoverable RSS feed without an RSS token" end end diff --git a/spec/features/projects/members/group_links_spec.rb b/spec/features/projects/members/group_links_spec.rb index ab2b089db2e..3d253f01484 100644 --- a/spec/features/projects/members/group_links_spec.rb +++ b/spec/features/projects/members/group_links_spec.rb @@ -20,7 +20,7 @@ feature 'Projects > Members > Anonymous user sees members', feature: true, js: t click_link 'Guest' end - wait_for_ajax + wait_for_requests visit namespace_project_settings_members_path(project.namespace, project) @@ -31,7 +31,7 @@ feature 'Projects > Members > Anonymous user sees members', feature: true, js: t tomorrow = Date.today + 3 fill_in "member_expires_at_#{group.id}", with: tomorrow.strftime("%F") - wait_for_ajax + wait_for_requests page.within(find('li.group_member')) do expect(page).to have_content('Expires in') @@ -42,7 +42,7 @@ feature 'Projects > Members > Anonymous user sees members', feature: true, js: t page.within(first('.group_member')) do find('.btn-remove').click end - wait_for_ajax + wait_for_requests expect(page).not_to have_selector('.group_member') 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 index 19d14ad9af4..1e6f15d8258 100644 --- 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 @@ -38,7 +38,7 @@ feature 'Projects > Members > Master adds member with expiration date', feature: page.within "#project_member_#{new_member.project_members.first.id}" do find('.js-access-expiration-date').set date.to_s(:medium) - wait_for_ajax + wait_for_requests expect(page).to have_content('Expires in 3 days') end end diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index de6a750c932..d428f6fcf22 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -67,7 +67,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending') end - scenario 'sorts by recent sign in' do + scenario 'sorts by recent sign in', :redis do visit_members_list(sort: :recent_sign_in) expect(first_member).to include(master.name) @@ -75,7 +75,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in') end - scenario 'sorts by oldest sign in' do + scenario 'sorts by oldest sign in', :redis do visit_members_list(sort: :oldest_sign_in) expect(first_member).to include(developer.name) diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index c66b9a34b86..b1f9eb15667 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -17,10 +17,10 @@ feature "New project", feature: true do expect(find_field("project_visibility_level_#{level}")).to be_checked end - it 'saves visibility level on validation error' do + it "saves visibility level #{level} on validation error" do visit new_project_path - choose(key) + choose(s_(key)) click_button('Create project') expect(find_field("project_visibility_level_#{level}")).to be_checked diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index 1211b17b3d8..ccd784131bc 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -2,10 +2,13 @@ require 'spec_helper' feature 'Pipeline Schedules', :feature do include PipelineSchedulesHelper - include WaitForAjax let!(:project) { create(:project) } +<<<<<<< HEAD let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) } +======= + let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project ) } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 let!(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) } let(:scope) { nil } let!(:user) { create(:user) } @@ -32,6 +35,7 @@ feature 'Pipeline Schedules', :feature do it 'displays the required information description' do page.within('.pipeline-schedule-table-row') do expect(page).to have_content('pipeline schedule') + expect(page).to have_content(pipeline_schedule.real_next_run.strftime('%b %d, %Y')) expect(page).to have_link('master') expect(page).to have_link("##{pipeline.id}") end @@ -65,6 +69,17 @@ feature 'Pipeline Schedules', :feature do expect(page).not_to have_content('pipeline schedule') end end + + context 'when ref is nil' do + before do + pipeline_schedule.update_attribute(:ref, nil) + visit_pipelines_schedules + end + + it 'shows a list of the pipeline schedules with empty ref column' do + expect(first('.branch-name-cell').text).to eq('') + end + end end describe 'POST /projects/pipeline_schedules/new', js: true do @@ -108,6 +123,19 @@ feature 'Pipeline Schedules', :feature do expect(page).to have_content('my brand new description') end + + context 'when ref is nil' do + before do + pipeline_schedule.update_attribute(:ref, nil) + edit_pipeline_schedule + end + + it 'shows the pipeline schedule with default ref' do + page.within('.git-revision-dropdown-toggle') do + expect(first('.dropdown-toggle-text').text).to eq('master') + end + end + end end def visit_new_pipeline_schedule diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index cfac54ef259..36a3ddca6ef 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -229,7 +229,6 @@ describe 'Pipeline', :feature, :js do before { find('.js-retry-button').trigger('click') } it { expect(page).not_to have_content('Retry') } - it { expect(page).to have_selector('.retried') } end end @@ -240,7 +239,6 @@ describe 'Pipeline', :feature, :js do before { click_on 'Cancel running' } it { expect(page).not_to have_content('Cancel running') } - it { expect(page).to have_selector('.ci-canceled') } end end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 8cc96c7b00f..05c2bf350f1 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -1,8 +1,6 @@ require 'spec_helper' describe 'Pipelines', :feature, :js do - include WaitForVueResource - let(:project) { create(:empty_project) } context 'when user is logged in' do @@ -22,7 +20,7 @@ describe 'Pipelines', :feature, :js do project: project, ref: 'master', status: 'running', - sha: project.commit.id, + sha: project.commit.id ) end @@ -54,7 +52,7 @@ describe 'Pipelines', :feature, :js do context 'header tabs' do before do visit namespace_project_pipelines_path(project.namespace, project) - wait_for_vue_resource + wait_for_requests end it 'shows a tab for All pipelines and count' do @@ -106,7 +104,7 @@ describe 'Pipelines', :feature, :js do context 'when canceling' do before do find('.js-pipelines-cancel-button').click - wait_for_vue_resource + wait_for_requests end it 'indicated that pipelines was canceled' do @@ -136,7 +134,7 @@ describe 'Pipelines', :feature, :js do context 'when retrying' do before do find('.js-pipelines-retry-button').click - wait_for_vue_resource + wait_for_requests end it 'shows running pipeline that is not retryable' do @@ -356,14 +354,14 @@ describe 'Pipelines', :feature, :js do it 'should render pagination' do visit namespace_project_pipelines_path(project.namespace, project) - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.gl-pagination') end it 'should render second page of pipelines' do visit namespace_project_pipelines_path(project.namespace, project, page: '2') - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('.gl-pagination .page', count: 2) end @@ -392,7 +390,7 @@ describe 'Pipelines', :feature, :js do create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3) visit namespace_project_pipeline_path(project.namespace, project, pipeline) - wait_for_vue_resource + wait_for_requests end it 'shows a graph with grouped stages' do @@ -444,6 +442,8 @@ describe 'Pipelines', :feature, :js do it 'creates a new pipeline' do expect { click_on 'Create pipeline' } .to change { Ci::Pipeline.count }.by(1) + + expect(Ci::Pipeline.last).to be_web end end @@ -507,6 +507,6 @@ describe 'Pipelines', :feature, :js do def visit_project_pipelines(**query) visit namespace_project_pipelines_path(project.namespace, project, query) - wait_for_vue_resource + wait_for_requests end end diff --git a/spec/features/projects/ref_switcher_spec.rb b/spec/features/projects/ref_switcher_spec.rb index 881ad7910dd..04414490571 100644 --- a/spec/features/projects/ref_switcher_spec.rb +++ b/spec/features/projects/ref_switcher_spec.rb @@ -12,12 +12,12 @@ feature 'Ref switcher', feature: true, js: true do it 'allow user to change ref by enter key' do click_button 'master' - wait_for_ajax + wait_for_requests page.within '.project-refs-form' do input = find('input[type="search"]') input.set 'binary' - wait_for_ajax + wait_for_requests expect(find('.dropdown-content ul')).to have_selector('li', count: 6) @@ -31,7 +31,7 @@ feature 'Ref switcher', feature: true, js: true do it "user selects ref with special characters" do click_button 'master' - wait_for_ajax + wait_for_requests page.within '.project-refs-form' do page.fill_in 'Search branches and tags', with: "'test'" diff --git a/spec/features/projects/services/jira_service_spec.rb b/spec/features/projects/services/jira_service_spec.rb new file mode 100644 index 00000000000..c96d87e5708 --- /dev/null +++ b/spec/features/projects/services/jira_service_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +feature 'Setup Jira service', :feature, :js do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:service) { project.create_jira_service } + + let(:url) { 'http://jira.example.com' } + let(:project_url) { 'http://username:password@jira.example.com/rest/api/2/project/GitLabProject' } + + def fill_form(active = true) + check 'Active' if active + + fill_in 'service_url', with: url + fill_in 'service_project_key', with: 'GitLabProject' + fill_in 'service_username', with: 'username' + fill_in 'service_password', with: 'password' + fill_in 'service_jira_issue_transition_id', with: '25' + end + + before do + project.team << [user, :master] + login_as(user) + + visit namespace_project_settings_integrations_path(project.namespace, project) + end + + describe 'user sets and activates Jira Service' do + context 'when Jira connection test succeeds' do + before do + WebMock.stub_request(:get, project_url) + end + + it 'activates the JIRA service' do + click_link('JIRA') + fill_form + click_button('Test settings and save changes') + wait_for_requests + + expect(page).to have_content('JIRA activated.') + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + end + end + + context 'when Jira connection test fails' do + before do + WebMock.stub_request(:get, project_url).to_return(status: 401) + end + + it 'shows errors when some required fields are not filled in' do + click_link('JIRA') + + check 'Active' + fill_in 'service_password', with: 'password' + click_button('Test settings and save changes') + + page.within('.service-settings') do + expect(page).to have_content('This field is required.') + end + end + + it 'activates the JIRA service' do + click_link('JIRA') + fill_form + click_button('Test settings and save changes') + wait_for_requests + + expect(find('.flash-container-page')).to have_content 'Test failed.' + expect(find('.flash-container-page')).to have_content 'Save anyway' + + find('.flash-alert .flash-action').trigger('click') + wait_for_requests + + expect(page).to have_content('JIRA activated.') + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + end + end + end + + describe 'user sets Jira Service but keeps it disabled' do + context 'when Jira connection test succeeds' do + it 'activates the JIRA service' do + click_link('JIRA') + fill_form(false) + click_button('Save changes') + + expect(page).to have_content('JIRA settings saved, but not activated.') + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + end + end + end +end diff --git a/spec/features/projects/services/mattermost_slash_command_spec.rb b/spec/features/projects/services/mattermost_slash_command_spec.rb index dc3854262e7..1fe82222e59 100644 --- a/spec/features/projects/services/mattermost_slash_command_spec.rb +++ b/spec/features/projects/services/mattermost_slash_command_spec.rb @@ -24,15 +24,25 @@ feature 'Setup Mattermost slash commands', :feature, :js do expect(token_placeholder).to eq('XXxxXXxxXXxxXXxxXXxxXXxx') end - it 'shows the token after saving' do + it 'redirects to the integrations page after saving but not activating' do token = ('a'..'z').to_a.join fill_in 'service_token', with: token - click_on 'Save' + click_on 'Save changes' - value = find_field('service_token').value + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + expect(page).to have_content('Mattermost slash commands settings saved, but not activated.') + end + + it 'redirects to the integrations page after activating' do + token = ('a'..'z').to_a.join + + fill_in 'service_token', with: token + check 'service_active' + click_on 'Save changes' - expect(value).to eq(token) + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + expect(page).to have_content('Mattermost slash commands activated.') end it 'shows the add to mattermost button' do diff --git a/spec/features/projects/services/slack_slash_command_spec.rb b/spec/features/projects/services/slack_slash_command_spec.rb index db903a0c8f0..f53b820c460 100644 --- a/spec/features/projects/services/slack_slash_command_spec.rb +++ b/spec/features/projects/services/slack_slash_command_spec.rb @@ -21,13 +21,21 @@ feature 'Slack slash commands', feature: true do expect(page).to have_content('This service allows users to perform common') end - it 'shows the token after saving' do + it 'redirects to the integrations page after saving but not activating' do fill_in 'service_token', with: 'token' click_on 'Save' - value = find_field('service_token').value + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + expect(page).to have_content('Slack slash commands settings saved, but not activated.') + end + + it 'redirects to the integrations page after activating' do + fill_in 'service_token', with: 'token' + check 'service_active' + click_on 'Save' - expect(value).to eq('token') + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + expect(page).to have_content('Slack slash commands activated.') end it 'shows the correct trigger url' do diff --git a/spec/features/projects/settings/integration_settings_spec.rb b/spec/features/projects/settings/integration_settings_spec.rb index 7909234556e..fbaea14a2be 100644 --- a/spec/features/projects/settings/integration_settings_spec.rb +++ b/spec/features/projects/settings/integration_settings_spec.rb @@ -52,6 +52,7 @@ feature 'Integration settings', feature: true do fill_in 'hook_url', with: url check 'Tag push events' check 'Enable SSL verification' + check 'Job events' click_button 'Add webhook' @@ -59,6 +60,7 @@ feature 'Integration settings', feature: true do expect(page).to have_content('SSL Verification: enabled') expect(page).to have_content('Push Events') expect(page).to have_content('Tag Push Events') + expect(page).to have_content('Job events') end scenario 'edit existing webhook' do @@ -83,11 +85,55 @@ feature 'Integration settings', feature: true do expect(current_path).to eq(integrations_path) end - scenario 'remove existing webhook' do - hook - visit integrations_path + context 'remove existing webhook' do + scenario 'from webhooks list page' do + hook + visit integrations_path + + expect { click_link 'Remove' }.to change(ProjectHook, :count).by(-1) + end + + scenario 'from webhook edit page' do + hook + visit integrations_path + click_link 'Edit' + + expect { click_link 'Remove' }.to change(ProjectHook, :count).by(-1) + end + end + end + + context 'Webhook logs' do + let(:hook) { create(:project_hook, project: project) } + let(:hook_log) { create(:web_hook_log, web_hook: hook, internal_error_message: 'some error') } + + scenario 'show list of hook logs' do + hook_log + visit edit_namespace_project_hook_path(project.namespace, project, hook) + + expect(page).to have_content('Recent Deliveries') + expect(page).to have_content(hook_log.url) + end + + scenario 'show hook log details' do + hook_log + visit edit_namespace_project_hook_path(project.namespace, project, hook) + click_link 'View details' + + expect(page).to have_content("POST #{hook_log.url}") + expect(page).to have_content(hook_log.internal_error_message) + expect(page).to have_content('Resend Request') + end + + scenario 'retry hook log' do + WebMock.stub_request(:post, hook.url) + + hook_log + visit edit_namespace_project_hook_path(project.namespace, project, hook) + click_link 'View details' + click_link 'Resend Request' - expect { click_link 'Remove' }.to change(ProjectHook, :count).by(-1) + expect(current_path).to eq(edit_namespace_project_hook_path(project.namespace, project, hook)) end end end diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb new file mode 100644 index 00000000000..4cc38c5286e --- /dev/null +++ b/spec/features/projects/settings/repository_settings_spec.rb @@ -0,0 +1,78 @@ +require 'spec_helper' + +feature 'Repository settings', feature: true do + let(:project) { create(:project_empty_repo) } + let(:user) { create(:user) } + let(:role) { :developer } + + background do + project.team << [user, role] + login_as(user) + end + + context 'for developer' do + given(:role) { :developer } + + scenario 'is not allowed to view' do + visit namespace_project_settings_repository_path(project.namespace, project) + + expect(page.status_code).to eq(404) + end + end + + context 'for master' do + given(:role) { :master } + + context 'Deploy Keys', js: true do + let(:private_deploy_key) { create(:deploy_key, title: 'private_deploy_key', public: false) } + let(:public_deploy_key) { create(:another_deploy_key, title: 'public_deploy_key', public: true) } + let(:new_ssh_key) { attributes_for(:key)[:key] } + + scenario 'get list of keys' do + project.deploy_keys << private_deploy_key + project.deploy_keys << public_deploy_key + + visit namespace_project_settings_repository_path(project.namespace, project) + + expect(page.status_code).to eq(200) + expect(page).to have_content('private_deploy_key') + expect(page).to have_content('public_deploy_key') + end + + scenario 'add a new deploy key' do + visit namespace_project_settings_repository_path(project.namespace, project) + + fill_in 'deploy_key_title', with: 'new_deploy_key' + fill_in 'deploy_key_key', with: new_ssh_key + check 'deploy_key_can_push' + click_button 'Add key' + + expect(page).to have_content('new_deploy_key') + expect(page).to have_content('Write access allowed') + end + + scenario 'edit an existing deploy key' do + project.deploy_keys << private_deploy_key + visit namespace_project_settings_repository_path(project.namespace, project) + + find('li', text: private_deploy_key.title).click_link('Edit') + + fill_in 'deploy_key_title', with: 'updated_deploy_key' + check 'deploy_key_can_push' + click_button 'Save changes' + + expect(page).to have_content('updated_deploy_key') + expect(page).to have_content('Write access allowed') + end + + scenario 'remove an existing deploy key' do + project.deploy_keys << private_deploy_key + visit namespace_project_settings_repository_path(project.namespace, project) + + find('li', text: private_deploy_key.title).click_button('Remove') + + expect(page).not_to have_content(private_deploy_key.title) + end + end + end +end diff --git a/spec/features/projects/settings/visibility_settings_spec.rb b/spec/features/projects/settings/visibility_settings_spec.rb index cef315ac9cd..fac4506bdf6 100644 --- a/spec/features/projects/settings/visibility_settings_spec.rb +++ b/spec/features/projects/settings/visibility_settings_spec.rb @@ -14,7 +14,7 @@ feature 'Visibility settings', feature: true, js: true do visibility_select_container = find('.js-visibility-select') expect(visibility_select_container.find('.visibility-select').value).to eq project.visibility_level.to_s - expect(visibility_select_container).to have_content 'The project can be cloned without any authentication.' + expect(visibility_select_container).to have_content 'The project can be accessed without any authentication.' end scenario 'project visibility description updates on change' do @@ -41,7 +41,7 @@ feature 'Visibility settings', feature: true, js: true do expect(visibility_select_container).not_to have_select '.visibility-select' expect(visibility_select_container).to have_content 'Public' - expect(visibility_select_container).to have_content 'The project can be cloned without any authentication.' + expect(visibility_select_container).to have_content 'The project can be accessed without any authentication.' end end end diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb new file mode 100644 index 00000000000..5ac1ca45c74 --- /dev/null +++ b/spec/features/projects/snippets/create_snippet_spec.rb @@ -0,0 +1,86 @@ +require 'rails_helper' + +feature 'Create Snippet', :js, feature: true do + include DropzoneHelper + + let(:user) { create(:user) } + let(:project) { create(:project, :repository, :public) } + + def fill_form + fill_in 'project_snippet_title', with: 'My Snippet Title' + fill_in 'project_snippet_description', with: 'My Snippet **Description**' + page.within('.file-editor') do + find('.ace_editor').native.send_keys('Hello World!') + end + end + + context 'when a user is authenticated' do + before do + project.team << [user, :master] + login_as(user) + + visit namespace_project_snippets_path(project.namespace, project) + + click_on('New snippet') + end + + it 'creates a new snippet' do + fill_form + click_button('Create snippet') + wait_for_requests + + expect(page).to have_content('My Snippet Title') + expect(page).to have_content('Hello World!') + page.within('.snippet-header .description') do + expect(page).to have_content('My Snippet Description') + expect(page).to have_selector('strong') + end + end + + it 'uploads a file when dragging into textarea' do + fill_form + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') + + expect(page.find_field("project_snippet_description").value).to have_content('banana_sample') + + click_button('Create snippet') + wait_for_requests + + link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] + expect(link).to match(%r{/#{Regexp.escape(project.full_path) }/uploads/\h{32}/banana_sample\.gif\z}) + end + + it 'creates a snippet when all reuiqred fields are filled in after validation failing' do + fill_in 'project_snippet_title', with: 'My Snippet Title' + click_button('Create snippet') + + expect(page).to have_selector('#error_explanation') + + fill_form + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') + + click_button('Create snippet') + wait_for_requests + + expect(page).to have_content('My Snippet Title') + expect(page).to have_content('Hello World!') + page.within('.snippet-header .description') do + expect(page).to have_content('My Snippet Description') + expect(page).to have_selector('strong') + end + link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] + expect(link).to match(%r{/#{Regexp.escape(project.full_path) }/uploads/\h{32}/banana_sample\.gif\z}) + end + end + + context 'when a user is not authenticated' do + it 'shows a public snippet on the index page but not the New snippet button' do + snippet = create(:project_snippet, :public, project: project) + + visit namespace_project_snippets_path(project.namespace, project) + + expect(page).to have_content(snippet.title) + expect(page).not_to have_content('New snippet') + end + end +end diff --git a/spec/features/projects/snippets/show_spec.rb b/spec/features/projects/snippets/show_spec.rb index cedf3778c7e..b844e60e5d5 100644 --- a/spec/features/projects/snippets/show_spec.rb +++ b/spec/features/projects/snippets/show_spec.rb @@ -17,7 +17,7 @@ feature 'Project snippet', :js, feature: true do before do visit namespace_project_snippet_path(project.namespace, project, snippet) - wait_for_ajax + wait_for_requests end it 'displays the blob' do @@ -48,7 +48,7 @@ feature 'Project snippet', :js, feature: true do before do visit namespace_project_snippet_path(project.namespace, project, snippet) - wait_for_ajax + wait_for_requests end it 'displays the blob using the rich viewer' do @@ -78,7 +78,7 @@ feature 'Project snippet', :js, feature: true do before do find('.js-blob-viewer-switch-btn[data-viewer=simple]').click - wait_for_ajax + wait_for_requests end it 'displays the blob using the simple viewer' do @@ -99,7 +99,7 @@ feature 'Project snippet', :js, feature: true do before do find('.js-blob-viewer-switch-btn[data-viewer=rich]').click - wait_for_ajax + wait_for_requests end it 'displays the blob using the rich viewer' do @@ -120,7 +120,7 @@ feature 'Project snippet', :js, feature: true do before do visit namespace_project_snippet_path(project.namespace, project, snippet, anchor: 'L1') - wait_for_ajax + wait_for_requests end it 'displays the blob using the simple viewer' do diff --git a/spec/features/projects/sub_group_issuables_spec.rb b/spec/features/projects/sub_group_issuables_spec.rb new file mode 100644 index 00000000000..e88907b8016 --- /dev/null +++ b/spec/features/projects/sub_group_issuables_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe 'Subgroup Issuables', :feature, :js, :nested_groups do + let!(:group) { create(:group, name: 'group') } + let!(:subgroup) { create(:group, parent: group, name: 'subgroup') } + let!(:project) { create(:empty_project, namespace: subgroup, name: 'project') } + let(:user) { create(:user) } + + before do + project.add_master(user) + login_as user + end + + it 'shows the full subgroup title when issues index page is empty' do + visit namespace_project_issues_path(project.namespace.to_param, project.to_param) + + expect_to_have_full_subgroup_title + end + + it 'shows the full subgroup title when merge requests index page is empty' do + visit namespace_project_merge_requests_path(project.namespace.to_param, project.to_param) + + expect_to_have_full_subgroup_title + end + + def expect_to_have_full_subgroup_title + title = find('.title-container') + + expect(title).not_to have_selector '.initializing' + expect(title).to have_content 'group / subgroup / project' + end +end diff --git a/spec/features/projects/tree/rss_spec.rb b/spec/features/projects/tree/rss_spec.rb index 9ac51997d65..9bf59c4139c 100644 --- a/spec/features/projects/tree/rss_spec.rb +++ b/spec/features/projects/tree/rss_spec.rb @@ -12,7 +12,7 @@ feature 'Project Tree RSS' do visit path end - it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end context 'when signed out' do @@ -20,6 +20,6 @@ feature 'Project Tree RSS' do visit path end - it_behaves_like "an autodiscoverable RSS feed without a private token" + it_behaves_like "an autodiscoverable RSS feed without an RSS token" end end diff --git a/spec/features/projects/view_on_env_spec.rb b/spec/features/projects/view_on_env_spec.rb index b7a41ca54e6..640f1376548 100644 --- a/spec/features/projects/view_on_env_spec.rb +++ b/spec/features/projects/view_on_env_spec.rb @@ -54,7 +54,7 @@ describe 'View on environment', js: true do visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request) - wait_for_ajax + wait_for_requests end it 'has a "View on env" button' do @@ -70,7 +70,7 @@ describe 'View on environment', js: true do visit namespace_project_compare_path(project.namespace, project, from: 'master', to: branch_name) - wait_for_ajax + wait_for_requests end it 'has a "View on env" button' do @@ -84,7 +84,7 @@ describe 'View on environment', js: true do visit namespace_project_compare_path(project.namespace, project, from: 'master', to: sha) - wait_for_ajax + wait_for_requests end it 'has a "View on env" button' do @@ -98,7 +98,7 @@ describe 'View on environment', js: true do visit namespace_project_blob_path(project.namespace, project, File.join(branch_name, file_path)) - wait_for_ajax + wait_for_requests end it 'has a "View on env" button' do @@ -112,7 +112,7 @@ describe 'View on environment', js: true do visit namespace_project_blob_path(project.namespace, project, File.join(sha, file_path)) - wait_for_ajax + wait_for_requests end it 'has a "View on env" button' do @@ -126,7 +126,7 @@ describe 'View on environment', js: true do visit namespace_project_commit_path(project.namespace, project, sha) - wait_for_ajax + wait_for_requests end it 'has a "View on env" button' do diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb index 43d8b45669e..49d7ef09e64 100644 --- a/spec/features/projects/wiki/markdown_preview_spec.rb +++ b/spec/features/projects/wiki/markdown_preview_spec.rb @@ -17,14 +17,14 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t login_as(user) visit namespace_project_path(project.namespace, project) - click_link 'Wiki' + find('.shortcuts-wiki').trigger('click') WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute end context "while creating a new wiki page" do context "when there are no spaces or hyphens in the page name" do it "rewrites relative links as expected" do - click_link 'New page' + find('.add-new-wiki').trigger('click') page.within '#modal-new-wiki' do fill_in :new_wiki_path, with: 'a/b/c/d' click_button 'Create page' @@ -73,7 +73,7 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t fill_in :new_wiki_path, with: 'a-page/b-page/c-page/d-page' click_button 'Create page' end - + page.within '.wiki-form' do fill_in :wiki_content, with: wiki_content click_on "Preview" @@ -91,7 +91,7 @@ feature 'Projects > Wiki > User previews markdown changes', feature: true, js: t context "while editing a wiki page" do def create_wiki_page(path) - click_link 'New page' + find('.add-new-wiki').trigger('click') page.within '#modal-new-wiki' do fill_in :new_wiki_path, with: path diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb index 1ffac8cd542..8912d575878 100644 --- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Projects > Wiki > User creates wiki page', feature: true do +feature 'Projects > Wiki > User creates wiki page', js: true, feature: true do let(:user) { create(:user) } background do @@ -8,7 +8,7 @@ feature 'Projects > Wiki > User creates wiki page', feature: true do login_as(user) visit namespace_project_path(project.namespace, project) - click_link 'Wiki' + find('.shortcuts-wiki').trigger('click') end context 'in the user namespace' do @@ -28,6 +28,40 @@ feature 'Projects > Wiki > User creates wiki page', feature: true do expect(page).to have_content("Last edited by #{user.name}") expect(page).to have_content('My awesome wiki!') end + + scenario 'creates ASCII wiki with LaTeX blocks' do + stub_application_setting(plantuml_url: 'http://localhost', plantuml_enabled: true) + + ascii_content = <<~MD + :stem: latexmath + + [stem] + ++++ + \sqrt{4} = 2 + ++++ + + another part + + [latexmath] + ++++ + \beta_x \gamma + ++++ + + stem:[2+2] is 4 + MD + + find('#wiki_format option[value=asciidoc]').select_option + fill_in :wiki_content, with: ascii_content + + page.within '.wiki-form' do + click_button 'Create page' + end + + page.within '.wiki' do + expect(page).to have_selector('.katex', count: 3) + expect(page).to have_content('2+2 is 4') + end + end end context 'when wiki is not empty' do diff --git a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb index 6825b95c8aa..95826e7e5be 100644 --- a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb @@ -21,6 +21,6 @@ describe 'Projects > Wiki > User views Git access wiki page', :feature do click_link 'Clone repository' expect(page).to have_text("Clone repository #{project.wiki.path_with_namespace}") - expect(page).to have_text(project.wiki.http_url_to_repo(user)) + expect(page).to have_text(project.wiki.http_url_to_repo) end end diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index fc9b293c393..667895bffa5 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -1,7 +1,6 @@ require 'spec_helper' -Dir["./spec/features/protected_branches/*.rb"].sort.each { |f| require f } -feature 'Projected Branches', feature: true, js: true do +feature 'Protected Branches', feature: true, js: true do let(:user) { create(:user, :admin) } let(:project) { create(:project, :repository) } diff --git a/spec/features/protected_tags_spec.rb b/spec/features/protected_tags_spec.rb index e68448467b0..66236dbc7fc 100644 --- a/spec/features/protected_tags_spec.rb +++ b/spec/features/protected_tags_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -Dir["./spec/features/protected_tags/*.rb"].sort.each { |f| require f } feature 'Projected Tags', feature: true, js: true do let(:user) { create(:user, :admin) } diff --git a/spec/features/reportable_note/commit_spec.rb b/spec/features/reportable_note/commit_spec.rb new file mode 100644 index 00000000000..39b1c4acf52 --- /dev/null +++ b/spec/features/reportable_note/commit_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe 'Reportable note on commit', :feature, :js do + include RepoHelpers + + let(:user) { create(:user) } + let(:project) { create(:project) } + + before do + project.add_master(user) + login_as user + end + + context 'a normal note' do + let!(:note) { create(:note_on_commit, commit_id: sample_commit.id, project: project) } + + before do + visit namespace_project_commit_path(project.namespace, project, sample_commit.id) + end + + it_behaves_like 'reportable note' + end + + context 'a diff note' do + let!(:note) { create(:diff_note_on_commit, commit_id: sample_commit.id, project: project) } + + before do + visit namespace_project_commit_path(project.namespace, project, sample_commit.id) + end + + it_behaves_like 'reportable note' + end +end diff --git a/spec/features/reportable_note/issue_spec.rb b/spec/features/reportable_note/issue_spec.rb new file mode 100644 index 00000000000..5f526818994 --- /dev/null +++ b/spec/features/reportable_note/issue_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe 'Reportable note on issue', :feature, :js do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:issue) { create(:issue, project: project) } + let!(:note) { create(:note_on_issue, noteable: issue, project: project) } + + before do + project.add_master(user) + login_as user + + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it_behaves_like 'reportable note' +end diff --git a/spec/features/reportable_note/merge_request_spec.rb b/spec/features/reportable_note/merge_request_spec.rb new file mode 100644 index 00000000000..6d053d26626 --- /dev/null +++ b/spec/features/reportable_note/merge_request_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe 'Reportable note on merge request', :feature, :js do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project) } + + before do + project.add_master(user) + login_as user + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + context 'a normal note' do + let!(:note) { create(:note_on_merge_request, noteable: merge_request, project: project) } + + it_behaves_like 'reportable note' + end + + context 'a diff note' do + let!(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } + + it_behaves_like 'reportable note' + end +end diff --git a/spec/features/reportable_note/snippets_spec.rb b/spec/features/reportable_note/snippets_spec.rb new file mode 100644 index 00000000000..3f1e0cf9097 --- /dev/null +++ b/spec/features/reportable_note/snippets_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe 'Reportable note on snippets', :feature, :js do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + + before do + project.add_master(user) + login_as user + end + + describe 'on project snippet' do + let(:snippet) { create(:project_snippet, :public, project: project, author: user) } + let!(:note) { create(:note_on_project_snippet, noteable: snippet, project: project) } + + before do + visit namespace_project_snippet_path(project.namespace, project, snippet) + end + + it_behaves_like 'reportable note' + end + + describe 'on personal snippet' do + let(:snippet) { create(:personal_snippet, :public, author: user) } + let!(:note) { create(:note_on_personal_snippet, noteable: snippet, author: user) } + + before do + visit snippet_path(snippet) + end + + it_behaves_like 'reportable note' + end +end diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 498a4a5cba0..7834807b1f1 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -20,14 +20,15 @@ describe "Search", feature: true do context 'search filters', js: true do let(:group) { create(:group) } + let!(:group_project) { create(:empty_project, group: group) } before do group.add_owner(user) end it 'shows group name after filtering' do - find('.js-search-group-dropdown').click - wait_for_ajax + find('.js-search-group-dropdown').trigger('click') + wait_for_requests page.within '.search-holder' do click_link group.name @@ -36,10 +37,28 @@ describe "Search", feature: true do expect(find('.js-search-group-dropdown')).to have_content(group.name) end + it 'filters by group projects after filtering by group' do + find('.js-search-group-dropdown').trigger('click') + wait_for_requests + + page.within '.search-holder' do + click_link group.name + end + + expect(find('.js-search-group-dropdown')).to have_content(group.name) + + page.within('.project-filter') do + find('.js-search-project-dropdown').trigger('click') + wait_for_requests + + expect(page).to have_link(group_project.name_with_namespace) + end + end + it 'shows project name after filtering' do page.within('.project-filter') do - find('.js-search-project-dropdown').click - wait_for_ajax + find('.js-search-project-dropdown').trigger('click') + wait_for_requests click_link project.name_with_namespace end diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 78a76d9c112..2a2655bbdb5 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -334,7 +334,7 @@ describe "Internal Project Access", feature: true do end describe "GET /:project_path/builds" do - subject { namespace_project_builds_path(project.namespace, project) } + subject { namespace_project_jobs_path(project.namespace, project) } context "when allowed for public and internal" do before { project.update(public_builds: true) } @@ -368,7 +368,7 @@ describe "Internal Project Access", feature: true do describe "GET /:project_path/builds/:id" do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { namespace_project_build_path(project.namespace, project, build.id) } + subject { namespace_project_job_path(project.namespace, project, build.id) } context "when allowed for public and internal" do before { project.update(public_builds: true) } @@ -402,7 +402,7 @@ describe "Internal Project Access", feature: true do describe 'GET /:project_path/builds/:id/trace' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { trace_namespace_project_build_path(project.namespace, project, build.id) } + subject { trace_namespace_project_job_path(project.namespace, project, build.id) } context 'when allowed for public and internal' do before do diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index a66f6e09055..b676c236758 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -330,7 +330,7 @@ describe "Private Project Access", feature: true do end describe "GET /:project_path/builds" do - subject { namespace_project_builds_path(project.namespace, project) } + subject { namespace_project_jobs_path(project.namespace, project) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } @@ -358,7 +358,7 @@ describe "Private Project Access", feature: true do describe "GET /:project_path/builds/:id" do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { namespace_project_build_path(project.namespace, project, build.id) } + subject { namespace_project_job_path(project.namespace, project, build.id) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } @@ -391,7 +391,7 @@ describe "Private Project Access", feature: true do describe 'GET /:project_path/builds/:id/trace' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { trace_namespace_project_build_path(project.namespace, project, build.id) } + subject { trace_namespace_project_job_path(project.namespace, project, build.id) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 5cd575500c3..35d5163941e 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -154,7 +154,7 @@ describe "Public Project Access", feature: true do end describe "GET /:project_path/builds" do - subject { namespace_project_builds_path(project.namespace, project) } + subject { namespace_project_jobs_path(project.namespace, project) } context "when allowed for public" do before { project.update(public_builds: true) } @@ -188,7 +188,7 @@ describe "Public Project Access", feature: true do describe "GET /:project_path/builds/:id" do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { namespace_project_build_path(project.namespace, project, build.id) } + subject { namespace_project_job_path(project.namespace, project, build.id) } context "when allowed for public" do before { project.update(public_builds: true) } @@ -222,7 +222,7 @@ describe "Public Project Access", feature: true do describe 'GET /:project_path/builds/:id/trace' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { trace_namespace_project_build_path(project.namespace, project, build.id) } + subject { trace_namespace_project_job_path(project.namespace, project, build.id) } context 'when allowed for public' do before do diff --git a/spec/features/snippets/create_snippet_spec.rb b/spec/features/snippets/create_snippet_spec.rb index 9409c323288..ddd31ede064 100644 --- a/spec/features/snippets/create_snippet_spec.rb +++ b/spec/features/snippets/create_snippet_spec.rb @@ -1,24 +1,93 @@ require 'rails_helper' feature 'Create Snippet', :js, feature: true do + include DropzoneHelper + before do login_as :user visit new_snippet_path end - scenario 'Authenticated user creates a snippet' do + def fill_form fill_in 'personal_snippet_title', with: 'My Snippet Title' + fill_in 'personal_snippet_description', with: 'My Snippet **Description**' page.within('.file-editor') do find('.ace_editor').native.send_keys 'Hello World!' end + end - click_button 'Create snippet' - wait_for_ajax + scenario 'Authenticated user creates a snippet' do + fill_form + + click_button('Create snippet') + wait_for_requests expect(page).to have_content('My Snippet Title') + page.within('.snippet-header .description') do + expect(page).to have_content('My Snippet Description') + expect(page).to have_selector('strong') + end expect(page).to have_content('Hello World!') end + scenario 'previews a snippet with file' do + fill_in 'personal_snippet_description', with: 'My Snippet' + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') + find('.js-md-preview-button').click + + page.within('#new_personal_snippet .md-preview') do + expect(page).to have_content('My Snippet') + + link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] + expect(link).to match(%r{/uploads/temp/\h{32}/banana_sample\.gif\z}) + + visit(link) + expect(page.status_code).to eq(200) + end + end + + scenario 'uploads a file when dragging into textarea' do + fill_form + + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') + + expect(page.find_field("personal_snippet_description").value).to have_content('banana_sample') + + click_button('Create snippet') + wait_for_requests + + link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] + expect(link).to match(%r{/uploads/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z}) + + visit(link) + expect(page.status_code).to eq(200) + end + + scenario 'validation fails for the first time' do + fill_in 'personal_snippet_title', with: 'My Snippet Title' + click_button('Create snippet') + + expect(page).to have_selector('#error_explanation') + + fill_form + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') + + click_button('Create snippet') + wait_for_requests + + expect(page).to have_content('My Snippet Title') + page.within('.snippet-header .description') do + expect(page).to have_content('My Snippet Description') + expect(page).to have_selector('strong') + end + expect(page).to have_content('Hello World!') + link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] + expect(link).to match(%r{/uploads/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z}) + + visit(link) + expect(page.status_code).to eq(200) + end + scenario 'Authenticated user creates a snippet with + in filename' do fill_in 'personal_snippet_title', with: 'My Snippet Title' page.within('.file-editor') do @@ -27,7 +96,7 @@ feature 'Create Snippet', :js, feature: true do end click_button 'Create snippet' - wait_for_ajax + wait_for_requests expect(page).to have_content('My Snippet Title') expect(page).to have_content('snippet+file+name') diff --git a/spec/features/snippets/edit_snippet_spec.rb b/spec/features/snippets/edit_snippet_spec.rb new file mode 100644 index 00000000000..89ae593db88 --- /dev/null +++ b/spec/features/snippets/edit_snippet_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +feature 'Edit Snippet', :js, feature: true do + include DropzoneHelper + + let(:file_name) { 'test.rb' } + let(:content) { 'puts "test"' } + + let(:user) { create(:user) } + let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, author: user) } + + before do + login_as(user) + + visit edit_snippet_path(snippet) + wait_for_requests + end + + it 'updates the snippet' do + fill_in 'personal_snippet_title', with: 'New Snippet Title' + + click_button('Save changes') + wait_for_requests + + expect(page).to have_content('New Snippet Title') + end + + it 'updates the snippet with files attached' do + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') + expect(page.find_field("personal_snippet_description").value).to have_content('banana_sample') + + click_button('Save changes') + wait_for_requests + + link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] + expect(link).to match(%r{/uploads/personal_snippet/#{snippet.id}/\h{32}/banana_sample\.gif\z}) + end +end diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb index 698eb46573f..44b0c89fac7 100644 --- a/spec/features/snippets/notes_on_personal_snippets_spec.rb +++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'Comments on personal snippets', :js, feature: true do + include NoteInteractionHelpers + let!(:user) { create(:user) } let!(:snippet) { create(:personal_snippet, :public) } let!(:snippet_notes) do @@ -22,6 +24,8 @@ describe 'Comments on personal snippets', :js, feature: true do it 'contains notes for a snippet with correct action icons' do expect(page).to have_selector('#notes-list li', count: 2) + open_more_actions_dropdown(snippet_notes[0]) + # comment authored by current user page.within("#notes-list li#note_#{snippet_notes[0].id}") do expect(page).to have_content(snippet_notes[0].note) @@ -29,6 +33,8 @@ describe 'Comments on personal snippets', :js, feature: true do expect(page).to have_selector('.note-emoji-button') end + open_more_actions_dropdown(snippet_notes[1]) + page.within("#notes-list li#note_#{snippet_notes[1].id}") do expect(page).to have_content(snippet_notes[1].note) expect(page).not_to have_selector('.js-note-delete') @@ -68,6 +74,8 @@ describe 'Comments on personal snippets', :js, feature: true do context 'when editing a note' do it 'changes the text' do + open_more_actions_dropdown(snippet_notes[0]) + page.within("#notes-list li#note_#{snippet_notes[0].id}") do click_on 'Edit comment' end @@ -89,11 +97,13 @@ describe 'Comments on personal snippets', :js, feature: true do context 'when deleting a note' do it 'removes the note from the snippet detail page' do + open_more_actions_dropdown(snippet_notes[0]) + page.within("#notes-list li#note_#{snippet_notes[0].id}") do - click_on 'Remove comment' + click_on 'Delete comment' end - wait_for_ajax + wait_for_requests expect(page).not_to have_selector("#notes-list li#note_#{snippet_notes[0].id}") end diff --git a/spec/features/snippets/public_snippets_spec.rb b/spec/features/snippets/public_snippets_spec.rb index 2df483818c3..afd945a8555 100644 --- a/spec/features/snippets/public_snippets_spec.rb +++ b/spec/features/snippets/public_snippets_spec.rb @@ -5,7 +5,7 @@ feature 'Public Snippets', :js, feature: true do public_snippet = create(:personal_snippet, :public) visit snippet_path(public_snippet) - wait_for_ajax + wait_for_requests expect(page).to have_content(public_snippet.content) end diff --git a/spec/features/snippets/show_spec.rb b/spec/features/snippets/show_spec.rb index e36cf547f80..95fc1d2bb62 100644 --- a/spec/features/snippets/show_spec.rb +++ b/spec/features/snippets/show_spec.rb @@ -11,7 +11,7 @@ feature 'Snippet', :js, feature: true do before do visit snippet_path(snippet) - wait_for_ajax + wait_for_requests end it 'displays the blob' do @@ -42,7 +42,7 @@ feature 'Snippet', :js, feature: true do before do visit snippet_path(snippet) - wait_for_ajax + wait_for_requests end it 'displays the blob using the rich viewer' do @@ -72,7 +72,7 @@ feature 'Snippet', :js, feature: true do before do find('.js-blob-viewer-switch-btn[data-viewer=simple]').click - wait_for_ajax + wait_for_requests end it 'displays the blob using the simple viewer' do @@ -93,7 +93,7 @@ feature 'Snippet', :js, feature: true do before do find('.js-blob-viewer-switch-btn[data-viewer=rich]').click - wait_for_ajax + wait_for_requests end it 'displays the blob using the rich viewer' do @@ -114,7 +114,7 @@ feature 'Snippet', :js, feature: true do before do visit snippet_path(snippet, anchor: 'L1') - wait_for_ajax + wait_for_requests end it 'displays the blob using the simple viewer' do diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index 8bd13caf2b0..563e65d3cc5 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -64,13 +64,11 @@ feature 'Task Lists', feature: true do describe 'for Issues', feature: true do describe 'multiple tasks', js: true do - include WaitForVueResource - let!(:issue) { create(:issue, description: markdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 6) @@ -79,7 +77,7 @@ feature 'Task Lists', feature: true do it 'contains the required selectors' do visit_issue(project, issue) - wait_for_vue_resource + wait_for_requests expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") expect(page).to have_selector('a.btn-close') @@ -87,14 +85,14 @@ feature 'Task Lists', feature: true do it 'is only editable by author' do visit_issue(project, issue) - wait_for_vue_resource + wait_for_requests expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") logout(:user) login_as(user2) visit current_path - wait_for_vue_resource + wait_for_requests expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") end @@ -106,13 +104,11 @@ feature 'Task Lists', feature: true do end describe 'single incomplete task', js: true do - include WaitForVueResource - let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) @@ -127,12 +123,11 @@ feature 'Task Lists', feature: true do end describe 'single complete task', js: true do - include WaitForVueResource let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) - wait_for_vue_resource + wait_for_requests expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) diff --git a/spec/features/todos/todos_filtering_spec.rb b/spec/features/todos/todos_filtering_spec.rb index f32e70c2c3f..bbfa4e08379 100644 --- a/spec/features/todos/todos_filtering_spec.rb +++ b/spec/features/todos/todos_filtering_spec.rb @@ -28,7 +28,7 @@ describe 'Dashboard > User filters todos', feature: true, js: true do click_link project_1.name_with_namespace end - wait_for_ajax + wait_for_requests expect(page).to have_content project_1.name_with_namespace expect(page).not_to have_content project_2.name_with_namespace @@ -43,7 +43,7 @@ describe 'Dashboard > User filters todos', feature: true, js: true do click_link user_1.name end - wait_for_ajax + wait_for_requests expect(find('.todos-list')).to have_content 'merge request' expect(find('.todos-list')).not_to have_content 'issue' @@ -90,7 +90,7 @@ describe 'Dashboard > User filters todos', feature: true, js: true do click_link 'Issue' end - wait_for_ajax + wait_for_requests expect(find('.todos-list')).to have_content issue.to_reference expect(find('.todos-list')).not_to have_content merge_request.to_reference @@ -132,7 +132,7 @@ describe 'Dashboard > User filters todos', feature: true, js: true do click_link name end - wait_for_ajax + wait_for_requests end def expect_to_see_action(action_name) diff --git a/spec/features/todos/todos_spec.rb b/spec/features/todos/todos_spec.rb index be5b3af417f..bb4b2aed0e3 100644 --- a/spec/features/todos/todos_spec.rb +++ b/spec/features/todos/todos_spec.rb @@ -64,7 +64,7 @@ describe 'Dashboard Todos', feature: true do before do within first('.todo') do click_link 'Done' - wait_for_ajax + wait_for_requests click_link 'Undo' end end @@ -251,7 +251,7 @@ describe 'Dashboard Todos', feature: true do describe 'mark all as done', js: true do before do visit dashboard_todos_path - click_link 'Mark all as done' + find('.js-todos-mark-all').trigger('click') end it 'shows "All done" message!' do @@ -308,10 +308,10 @@ describe 'Dashboard Todos', feature: true do end def mark_all_and_undo - click_link 'Mark all as done' - wait_for_ajax - click_link 'Undo mark all as done' - wait_for_ajax + find('.js-todos-mark-all').trigger('click') + wait_for_requests + find('.js-todos-undo-all').trigger('click') + wait_for_requests end end end diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb index 544d2dcb87f..2fed8067042 100644 --- a/spec/features/u2f_spec.rb +++ b/spec/features/u2f_spec.rb @@ -6,7 +6,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do def manage_two_factor_authentication click_on 'Manage two-factor authentication' expect(page).to have_content("Setup new U2F device") - wait_for_ajax + wait_for_requests end def register_u2f_device(u2f_device = nil, name: 'My device') diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb index a23c4ca2b92..8509551ce4a 100644 --- a/spec/features/unsubscribe_links_spec.rb +++ b/spec/features/unsubscribe_links_spec.rb @@ -24,8 +24,8 @@ describe 'Unsubscribe links', feature: true 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(page).to have_text(%(Unsubscribe from issue)) + expect(page).to have_text(%(Are you sure you want to unsubscribe from the issue: #{issue.title} (#{issue.to_reference})?)) expect(issue.subscribed?(recipient, project)).to be_truthy click_link 'Unsubscribe' diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb index 0c160dd74b4..9332d3b88d2 100644 --- a/spec/features/uploads/user_uploads_file_to_note_spec.rb +++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb @@ -5,18 +5,78 @@ feature 'User uploads file to note', feature: true do let(:user) { create(:user) } let(:project) { create(:empty_project, creator: user, namespace: user.namespace) } + let(:issue) { create(:issue, project: project, author: user) } - scenario 'they see the attached file', js: true do - issue = create(:issue, project: project, author: user) - + before do login_as(user) visit namespace_project_issue_path(project.namespace, project, issue) + end + + context 'before uploading' do + it 'shows "Attach a file" button', js: true do + expect(page).to have_button('Attach a file') + expect(page).not_to have_selector('.uploading-progress-container', visible: true) + end + end + + context 'uploading is in progress' do + it 'shows "Cancel" button on uploading', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + + expect(page).to have_button('Cancel') + end + + it 'cancels uploading on clicking to "Cancel" button', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + + click_button 'Cancel' + + expect(page).to have_button('Attach a file') + expect(page).not_to have_button('Cancel') + expect(page).not_to have_selector('.uploading-progress-container', visible: true) + end + + it 'shows "Attaching a file" message on uploading 1 file', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + + expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching a file -') + end + + it 'shows "Attaching 2 files" message on uploading 2 file', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'video_sample.mp4'), + Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + + expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching 2 files -') + end + + it 'shows error message, "retry" and "attach a new file" link a if file is too big', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'video_sample.mp4')], 0.01) + + error_text = 'File is too big (0.06MiB). Max filesize: 0.01MiB.' + + expect(page).to have_selector('.uploading-error-message', visible: true, text: error_text) + expect(page).to have_selector('.retry-uploading-link', visible: true, text: 'Try again') + expect(page).to have_selector('.attach-new-file', visible: true, text: 'attach a new file') + expect(page).not_to have_button('Attach a file') + end + end + + context 'uploading is complete' do + it 'shows "Attach a file" button on uploading complete', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')]) + wait_for_requests + + expect(page).to have_button('Attach a file') + expect(page).not_to have_selector('.uploading-progress-container', visible: true) + end - dropzone_file(Rails.root.join('spec', 'fixtures', 'dk.png')) - click_button 'Comment' - wait_for_ajax + scenario 'they see the attached file', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')]) + click_button 'Comment' + wait_for_requests - expect(find('a.no-attachment-icon img[alt="dk"]')['src']) - .to match(%r{/#{project.full_path}/uploads/\h{32}/dk\.png$}) + expect(find('a.no-attachment-icon img[alt="dk"]')['src']) + .to match(%r{/#{project.full_path}/uploads/\h{32}/dk\.png$}) + end end end diff --git a/spec/features/user_callout_spec.rb b/spec/features/user_callout_spec.rb index 848af5e3a4d..b84f834ff1e 100644 --- a/spec/features/user_callout_spec.rb +++ b/spec/features/user_callout_spec.rb @@ -20,7 +20,7 @@ describe 'User Callouts', js: true do visit dashboard_projects_path within('.user-callout') do - find('.close').click + find('.close').trigger('click') end visit dashboard_projects_path diff --git a/spec/features/users/projects_spec.rb b/spec/features/users/projects_spec.rb index 373b64808f8..67ce4b44464 100644 --- a/spec/features/users/projects_spec.rb +++ b/spec/features/users/projects_spec.rb @@ -16,7 +16,7 @@ describe 'Projects tab on a user profile', :feature, :js do click_link('Personal projects') end - wait_for_ajax + wait_for_requests end it 'paginates results' do diff --git a/spec/features/users/rss_spec.rb b/spec/features/users/rss_spec.rb index 14564abb16d..dbd5f66b55e 100644 --- a/spec/features/users/rss_spec.rb +++ b/spec/features/users/rss_spec.rb @@ -9,7 +9,7 @@ feature 'User RSS' do visit path end - it_behaves_like "it has an RSS button with current_user's private token" + it_behaves_like "it has an RSS button with current_user's RSS token" end context 'when signed out' do @@ -17,6 +17,6 @@ feature 'User RSS' do visit path end - it_behaves_like "it has an RSS button without a private token" + it_behaves_like "it has an RSS button without an RSS token" end end diff --git a/spec/features/users/snippets_spec.rb b/spec/features/users/snippets_spec.rb index 4efbd672322..a39276543a8 100644 --- a/spec/features/users/snippets_spec.rb +++ b/spec/features/users/snippets_spec.rb @@ -11,7 +11,11 @@ describe 'Snippets tab on a user profile', feature: true, js: true do allow(Snippet).to receive(:default_per_page).and_return(1) visit user_path(user) page.within('.user-profile-nav') { click_link 'Snippets' } +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end it_behaves_like 'paginated snippets', remote: true @@ -27,7 +31,11 @@ describe 'Snippets tab on a user profile', feature: true, js: true do login_as(:user) visit user_path(user) page.within('.user-profile-nav') { click_link 'Snippets' } +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_selector('.snippet-row', count: 2) @@ -38,7 +46,11 @@ describe 'Snippets tab on a user profile', feature: true, js: true do it 'contains only public snippets of a user when a user is not logged in' do visit user_path(user) page.within('.user-profile-nav') { click_link 'Snippets' } +<<<<<<< HEAD wait_for_ajax +======= + wait_for_requests +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(page).to have_selector('.snippet-row', count: 1) expect(page).to have_content(public_snippet.title) diff --git a/spec/features/users_spec.rb b/spec/features/users_spec.rb index c43feadc808..fbe078bd136 100644 --- a/spec/features/users_spec.rb +++ b/spec/features/users_spec.rb @@ -78,25 +78,25 @@ feature 'Users', feature: true, js: true do scenario 'doesn\'t show an error border if the username is available' do fill_in username_input, with: 'new-user' - wait_for_ajax + wait_for_requests expect(find('.username')).not_to have_css '.gl-field-error-outline' end scenario 'does not show an error border if the username contains dots (.)' do fill_in username_input, with: 'new.user.username' - wait_for_ajax + wait_for_requests expect(find('.username')).not_to have_css '.gl-field-error-outline' end scenario 'shows an error border if the username already exists' do fill_in username_input, with: user.username - wait_for_ajax + wait_for_requests expect(find('.username')).to have_css '.gl-field-error-outline' end scenario 'shows an error border if the username contains special characters' do fill_in username_input, with: 'new$user!username' - wait_for_ajax + wait_for_requests expect(find('.username')).to have_css '.gl-field-error-outline' end end diff --git a/spec/features/variables_spec.rb b/spec/features/variables_spec.rb index b83a230c1f8..d0c982919db 100644 --- a/spec/features/variables_spec.rb +++ b/spec/features/variables_spec.rb @@ -19,7 +19,7 @@ describe 'Project variables', js: true do end end - it 'adds new variable' do + it 'adds new secret variable' do fill_in('variable_key', with: 'key') fill_in('variable_value', with: 'key value') click_button('Add new variable') @@ -27,6 +27,7 @@ describe 'Project variables', js: true do expect(page).to have_content('Variables were successfully updated.') page.within('.variables-table') do expect(page).to have_content('key') + expect(page).to have_content('No') end end @@ -41,6 +42,19 @@ describe 'Project variables', js: true do end end + it 'adds new protected variable' do + fill_in('variable_key', with: 'key') + fill_in('variable_value', with: 'value') + check('Protected') + click_button('Add new variable') + + expect(page).to have_content('Variables were successfully updated.') + page.within('.variables-table') do + expect(page).to have_content('key') + expect(page).to have_content('Yes') + end + end + it 'reveals and hides new variable' do fill_in('variable_key', with: 'key') fill_in('variable_value', with: 'key value') @@ -85,7 +99,7 @@ describe 'Project variables', js: true do click_button('Save variable') expect(page).to have_content('Variable was successfully updated.') - expect(project.variables.first.value).to eq('key value') + expect(project.variables(true).first.value).to eq('key value') end it 'edits variable with empty value' do @@ -98,6 +112,34 @@ describe 'Project variables', js: true do click_button('Save variable') expect(page).to have_content('Variable was successfully updated.') - expect(project.variables.first.value).to eq('') + expect(project.variables(true).first.value).to eq('') + end + + it 'edits variable to be protected' do + page.within('.variables-table') do + find('.btn-variable-edit').click + end + + expect(page).to have_content('Update variable') + check('Protected') + click_button('Save variable') + + expect(page).to have_content('Variable was successfully updated.') + expect(project.variables(true).first).to be_protected + end + + it 'edits variable to be unprotected' do + project.variables.first.update(protected: true) + + page.within('.variables-table') do + find('.btn-variable-edit').click + end + + expect(page).to have_content('Update variable') + uncheck('Protected') + click_button('Save variable') + + expect(page).to have_content('Variable was successfully updated.') + expect(project.variables(true).first).not_to be_protected end end diff --git a/spec/finders/events_finder_spec.rb b/spec/finders/events_finder_spec.rb new file mode 100644 index 00000000000..30a2bd14f10 --- /dev/null +++ b/spec/finders/events_finder_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe EventsFinder do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let(:project1) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) } + let(:project2) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) } + let(:closed_issue) { create(:closed_issue, project: project1, author: user) } + let(:opened_merge_request) { create(:merge_request, source_project: project2, author: user) } + let!(:closed_issue_event) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) } + let!(:opened_merge_request_event) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 1, 31)) } + let(:closed_issue2) { create(:closed_issue, project: project1, author: user) } + let(:opened_merge_request2) { create(:merge_request, source_project: project2, author: user) } + let!(:closed_issue_event2) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 2, 2)) } + let!(:opened_merge_request_event2) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 2, 2)) } + + context 'when targeting a user' do + it 'returns events between specified dates filtered on action and type' do + events = described_class.new(source: user, current_user: user, action: 'created', target_type: 'merge_request', after: Date.new(2017, 1, 1), before: Date.new(2017, 2, 1)).execute + + expect(events).to eq([opened_merge_request_event]) + end + + it 'does not return events the current_user does not have access to' do + events = described_class.new(source: user, current_user: other_user).execute + + expect(events).not_to include(opened_merge_request_event) + end + end + + context 'when targeting a project' do + it 'returns project events between specified dates filtered on action and type' do + events = described_class.new(source: project1, current_user: user, action: 'closed', target_type: 'issue', after: Date.new(2016, 12, 1), before: Date.new(2017, 1, 1)).execute + + expect(events).to eq([closed_issue_event]) + end + + it 'does not return events the current_user does not have access to' do + events = described_class.new(source: project2, current_user: other_user).execute + + expect(events).to be_empty + end + end +end diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index 148adcffe3b..03d98459e8c 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -137,6 +137,13 @@ describe ProjectsFinder do it { is_expected.to eq([public_project]) } end + describe 'filter by owned' do + let(:params) { { owned: true } } + let!(:owned_project) { create(:empty_project, :private, namespace: current_user.namespace) } + + it { is_expected.to eq([owned_project]) } + end + describe 'filter by non_public' do let(:params) { { non_public: true } } before do @@ -146,13 +153,19 @@ describe ProjectsFinder do it { is_expected.to eq([private_project]) } end - describe 'filter by viewable_starred_projects' do + describe 'filter by starred' do let(:params) { { starred: true } } before do current_user.toggle_star(public_project) end it { is_expected.to eq([public_project]) } + + it 'returns only projects the user has access to' do + current_user.toggle_star(private_project) + + is_expected.to eq([public_project]) + end end describe 'sorting' do diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb new file mode 100644 index 00000000000..780b309b45e --- /dev/null +++ b/spec/finders/users_finder_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe UsersFinder do + describe '#execute' do + let!(:user1) { create(:user, username: 'johndoe') } + let!(:user2) { create(:user, :blocked, username: 'notsorandom') } + let!(:external_user) { create(:user, :external) } + let!(:omniauth_user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') } + + context 'with a normal user' do + let(:user) { create(:user) } + + it 'returns all users' do + users = described_class.new(user).execute + + expect(users).to contain_exactly(user, user1, user2, omniauth_user) + end + + it 'filters by username' do + users = described_class.new(user, username: 'johndoe').execute + + expect(users).to contain_exactly(user1) + end + + it 'filters by search' do + users = described_class.new(user, search: 'orando').execute + + expect(users).to contain_exactly(user2) + end + + it 'filters by blocked users' do + users = described_class.new(user, blocked: true).execute + + expect(users).to contain_exactly(user2) + end + + it 'filters by active users' do + users = described_class.new(user, active: true).execute + + expect(users).to contain_exactly(user, user1, omniauth_user) + end + + it 'returns no external users' do + users = described_class.new(user, external: true).execute + + expect(users).to contain_exactly(user, user1, user2, omniauth_user) + end + end + + context 'with an admin user' do + let(:admin) { create(:admin) } + + it 'filters by external users' do + users = described_class.new(admin, external: true).execute + + expect(users).to contain_exactly(external_user) + end + + it 'returns all users' do + users = described_class.new(admin).execute + + expect(users).to contain_exactly(admin, user1, user2, external_user, omniauth_user) + end + end + end +end diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json index 7dda62ca3e7..f5e68b7a628 100644 --- a/spec/fixtures/api/schemas/entities/merge_request.json +++ b/spec/fixtures/api/schemas/entities/merge_request.json @@ -85,13 +85,22 @@ "email_patches_path": { "type": "string" }, "plain_diff_path": { "type": "string" }, "status_path": { "type": "string" }, +<<<<<<< HEAD +======= + "new_blob_path": { "type": "string" }, +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 "merge_check_path": { "type": "string" }, "ci_environments_status_path": { "type": "string" }, "merge_commit_message_with_description": { "type": "string" }, "diverged_commits_count": { "type": "integer" }, "commit_change_content_path": { "type": "string" }, "remove_wip_path": { "type": "string" }, +<<<<<<< HEAD "commits_count": { "type": "integer" } +======= + "commits_count": { "type": "integer" }, + "remove_source_branch": { "type": ["boolean", "null"] } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/list.json b/spec/fixtures/api/schemas/list.json index 11a4caf6628..622a1e40d07 100644 --- a/spec/fixtures/api/schemas/list.json +++ b/spec/fixtures/api/schemas/list.json @@ -10,7 +10,7 @@ "id": { "type": "integer" }, "list_type": { "type": "string", - "enum": ["label", "closed"] + "enum": ["backlog", "label", "closed"] }, "label": { "type": ["object", "null"], diff --git a/spec/fixtures/api/schemas/pipeline_schedule.json b/spec/fixtures/api/schemas/pipeline_schedule.json new file mode 100644 index 00000000000..f6346bd0fb6 --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline_schedule.json @@ -0,0 +1,41 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "description": { "type": "string" }, + "ref": { "type": "string" }, + "cron": { "type": "string" }, + "cron_timezone": { "type": "string" }, + "next_run_at": { "type": "date" }, + "active": { "type": "boolean" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "last_pipeline": { + "type": ["object", "null"], + "properties": { + "id": { "type": "integer" }, + "sha": { "type": "string" }, + "ref": { "type": "string" }, + "status": { "type": "string" } + }, + "additionalProperties": false + }, + "owner": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + } + }, + "required": [ + "id", "description", "ref", "cron", "cron_timezone", "next_run_at", + "active", "created_at", "updated_at", "owner" + ], + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/pipeline_schedules.json b/spec/fixtures/api/schemas/pipeline_schedules.json new file mode 100644 index 00000000000..173a28d2505 --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline_schedules.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "pipeline_schedule.json" } +} diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 1c4ea46f9cd..9793e3fb6dc 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -4,6 +4,8 @@ require 'spec_helper' describe ApplicationHelper do include UploadHelpers + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } + describe 'current_controller?' do it 'returns true when controller matches argument' do stub_controller_name('foo') @@ -57,8 +59,18 @@ describe ApplicationHelper do describe 'project_icon' do it 'returns an url for the avatar' do project = create(:empty_project, avatar: File.open(uploaded_image_temp_path)) + avatar_url = "/uploads/project/avatar/#{project.id}/banana_sample.gif" + + expect(helper.project_icon(project.full_path).to_s). + to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />" + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + avatar_url = "#{gitlab_host}/uploads/project/avatar/#{project.id}/banana_sample.gif" + +<<<<<<< HEAD avatar_url = "http://#{Gitlab.config.gitlab.host}/uploads/system/project/avatar/#{project.id}/banana_sample.gif" +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(helper.project_icon(project.full_path).to_s). to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />" end @@ -68,9 +80,8 @@ describe ApplicationHelper do allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true) - avatar_url = "http://#{Gitlab.config.gitlab.host}#{namespace_project_avatar_path(project.namespace, project)}" - expect(helper.project_icon(project.full_path).to_s).to match( - image_tag(avatar_url)) + avatar_url = "#{gitlab_host}#{namespace_project_avatar_path(project.namespace, project)}" + expect(helper.project_icon(project.full_path).to_s).to match(image_tag(avatar_url)) end end @@ -78,8 +89,19 @@ describe ApplicationHelper do it 'returns an url for the avatar' do user = create(:user, avatar: File.open(uploaded_image_temp_path)) +<<<<<<< HEAD expect(helper.avatar_icon(user.email).to_s). to match("/uploads/system/user/avatar/#{user.id}/banana_sample.gif") +======= + avatar_url = "/uploads/user/avatar/#{user.id}/banana_sample.gif" + + expect(helper.avatar_icon(user.email).to_s).to match(avatar_url) + + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + avatar_url = "#{gitlab_host}/uploads/user/avatar/#{user.id}/banana_sample.gif" + + expect(helper.avatar_icon(user.email).to_s).to match(avatar_url) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end it 'returns an url for the avatar with relative url' do diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb index 581726c1d0e..049475a5408 100644 --- a/spec/helpers/avatars_helper_spec.rb +++ b/spec/helpers/avatars_helper_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe AvatarsHelper do + include ApplicationHelper + let(:user) { create(:user) } describe '#user_avatar' do @@ -15,7 +17,106 @@ describe AvatarsHelper do end it "contains the user's avatar image" do - is_expected.to include(CGI.escapeHTML(user.avatar_url(16))) + is_expected.to include(CGI.escapeHTML(user.avatar_url(size: 16))) + end + end + + describe '#user_avatar_without_link' do + let(:options) { { user: user } } + subject { helper.user_avatar_without_link(options) } + + it 'displays user avatar' do + is_expected.to eq image_tag( + avatar_icon(user, 16), + class: 'avatar has-tooltip s16 ', + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body' } + ) + end + + context 'with css_class parameter' do + let(:options) { { user: user, css_class: '.cat-pics' } } + + it 'uses provided css_class' do + is_expected.to eq image_tag( + avatar_icon(user, 16), + class: "avatar has-tooltip s16 #{options[:css_class]}", + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body' } + ) + end + end + + context 'with lazy parameter' do + let(:options) { { user: user, lazy: true } } + + it 'uses data-src instead of src' do + is_expected.to eq image_tag( + '', + class: 'avatar has-tooltip s16 ', + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body', src: avatar_icon(user, 16) } + ) + end + end + + context 'with size parameter' do + let(:options) { { user: user, size: 99 } } + + it 'uses provided size' do + is_expected.to eq image_tag( + avatar_icon(user, options[:size]), + class: "avatar has-tooltip s#{options[:size]} ", + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body' } + ) + end + end + + context 'with url parameter' do + let(:options) { { user: user, url: '/over/the/rainbow.png' } } + + it 'uses provided url' do + is_expected.to eq image_tag( + options[:url], + class: 'avatar has-tooltip s16 ', + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body' } + ) + end + end + + context 'with user_name parameter' do + let(:options) { { user_name: 'Tinky Winky', user_email: 'no@f.un' } } + + context 'with user parameter' do + let(:options) { { user: user, user_name: 'Tinky Winky' } } + + it 'prefers user parameter' do + is_expected.to eq image_tag( + avatar_icon(user, 16), + class: 'avatar has-tooltip s16 ', + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body' } + ) + end + end + + it 'uses user_name and user_email parameter if user is not present' do + is_expected.to eq image_tag( + avatar_icon(options[:user_email], 16), + class: 'avatar has-tooltip s16 ', + alt: "#{options[:user_name]}'s avatar", + title: options[:user_name], + data: { container: 'body' } + ) + end end end end diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index 1b4393e6167..bd3a3d24b84 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -116,10 +116,11 @@ describe BlobHelper do let(:viewer_class) do Class.new(BlobViewer::Base) do - self.max_size = 1.megabyte - self.absolute_max_size = 5.megabytes + include BlobViewer::ServerSide + + self.collapse_limit = 1.megabyte + self.size_limit = 5.megabytes self.type = :rich - self.client_side = false end end @@ -128,7 +129,7 @@ describe BlobHelper do describe '#blob_render_error_reason' do context 'for error :too_large' do - context 'when the blob size is larger than the absolute max size' do + context 'when the blob size is larger than the absolute size limit' do let(:blob) { fake_blob(size: 10.megabytes) } it 'returns an error message' do @@ -136,7 +137,7 @@ describe BlobHelper do end end - context 'when the blob size is larger than the max size' do + context 'when the blob size is larger than the size limit' do let(:blob) { fake_blob(size: 2.megabytes) } it 'returns an error message' do @@ -167,21 +168,19 @@ describe BlobHelper do controller.params[:id] = File.join('master', blob.path) end - context 'for error :too_large' do - context 'when the max size can be overridden' do - let(:blob) { fake_blob(size: 2.megabytes) } + context 'for error :collapsed' do + let(:blob) { fake_blob(size: 2.megabytes) } - it 'includes a "load it anyway" link' do - expect(helper.blob_render_error_options(viewer)).to include(/load it anyway/) - end + it 'includes a "load it anyway" link' do + expect(helper.blob_render_error_options(viewer)).to include(/load it anyway/) end + end - context 'when the max size cannot be overridden' do - let(:blob) { fake_blob(size: 10.megabytes) } + context 'for error :too_large' do + let(:blob) { fake_blob(size: 10.megabytes) } - it 'does not include a "load it anyway" link' do - expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/) - end + it 'does not include a "load it anyway" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/) end context 'when the viewer is rich' do diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb index eae097126ce..a74615e07f9 100644 --- a/spec/helpers/diff_helper_spec.rb +++ b/spec/helpers/diff_helper_spec.rb @@ -33,17 +33,17 @@ describe DiffHelper do describe 'diff_options' do it 'returns no collapse false' do - expect(diff_options).to include(no_collapse: false) + expect(diff_options).to include(expanded: false) end - 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) + it 'returns no collapse true if expanded' do + allow(controller).to receive(:params) { { expanded: true } } + expect(diff_options).to include(expanded: true) end 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) + expect(diff_options).to include(expanded: true) end it 'returns paths if action name diff_for_path and param old path' do @@ -122,13 +122,40 @@ describe DiffHelper do it "returns strings with marked inline diffs" do marked_old_line, marked_new_line = mark_inline_diffs(old_line, new_line) - expect(marked_old_line).to eq("abc <span class='idiff left right deletion'>'def'</span>") + expect(marked_old_line).to eq(%q{abc <span class="idiff left right deletion">'def'</span>}) expect(marked_old_line).to be_html_safe - expect(marked_new_line).to eq("abc <span class='idiff left right addition'>"def"</span>") + expect(marked_new_line).to eq(%q{abc <span class="idiff left right addition">"def"</span>}) expect(marked_new_line).to be_html_safe end end + describe '#parallel_diff_discussions' do + let(:discussion) { { 'abc_3_3' => 'comment' } } + let(:diff_file) { double(line_code: 'abc_3_3') } + + before do + helper.instance_variable_set(:@grouped_diff_discussions, discussion) + end + + it 'does not put comments on nonewline lines' do + left = Gitlab::Diff::Line.new('\\nonewline', 'old-nonewline', 3, 3, 3) + right = Gitlab::Diff::Line.new('\\nonewline', 'new-nonewline', 3, 3, 3) + + result = helper.parallel_diff_discussions(left, right, diff_file) + + expect(result).to eq([nil, nil]) + end + + it 'puts comments on added lines' do + left = Gitlab::Diff::Line.new('\\nonewline', 'old-nonewline', 3, 3, 3) + right = Gitlab::Diff::Line.new('new line', 'add', 3, 3, 3) + + result = helper.parallel_diff_discussions(left, right, diff_file) + + expect(result).to eq([nil, 'comment']) + end + end + describe "#diff_match_line" do let(:old_pos) { 40 } let(:new_pos) { 50 } diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index c1ecb46aece..8fcf7f5fa15 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -192,4 +192,22 @@ describe IssuablesHelper do expect(helper.issuable_filter_present?).to be_falsey end end + + describe '#updated_at_by' do + let(:user) { create(:user) } + let(:unedited_issuable) { create(:issue) } + let(:edited_issuable) { create(:issue, last_edited_by: user, created_at: 3.days.ago, updated_at: 2.days.ago, last_edited_at: 2.days.ago) } + let(:edited_updated_at_by) do + { + updatedAt: edited_issuable.updated_at.to_time.iso8601, + updatedBy: { + name: user.name, + path: user_path(user) + } + } + end + + it { expect(helper.updated_at_by(unedited_issuable)).to eq({}) } + it { expect(helper.updated_at_by(edited_issuable)).to eq(edited_updated_at_by) } + end end diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index 099146678ae..cc861af8533 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -92,7 +92,13 @@ describe NotesHelper do ) end - let(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position).to_discussion } + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) } + let(:discussion) { diff_note.to_discussion } + + before do + diff_note.position = diff_note.original_position + diff_note.save! + end it 'returns the diff version comparison path with the line code' do expect(helper.discussion_path(discussion)).to eq(diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, diff_id: merge_request_diff3, start_sha: merge_request_diff1.head_commit_sha, anchor: discussion.line_code)) @@ -250,4 +256,14 @@ describe NotesHelper do expect(helper.form_resources).to eq([@project.namespace, @project, @note]) end end + + describe '#noteable_note_url' do + let(:project) { create(:empty_project) } + let(:issue) { create(:issue, project: project) } + let(:note) { create(:note_on_issue, noteable: issue, project: project) } + + it 'returns the noteable url with an anchor to the note' do + expect(noteable_note_url(note)).to match("/#{project.namespace.path}/#{project.path}/issues/#{issue.iid}##{dom_id(note)}") + end + end end diff --git a/spec/helpers/notifications_helper_spec.rb b/spec/helpers/notifications_helper_spec.rb index 9d5f009ebe1..9ecaabc04ed 100644 --- a/spec/helpers/notifications_helper_spec.rb +++ b/spec/helpers/notifications_helper_spec.rb @@ -12,5 +12,11 @@ describe NotificationsHelper do describe 'notification_title' do it { expect(notification_title(:watch)).to match('Watch') } it { expect(notification_title(:mention)).to match('On mention') } + it { expect(notification_title(:global)).to match('Global') } + end + + describe '#notification_event_name' do + it { expect(notification_event_name(:success_pipeline)).to match('Successful pipeline') } + it { expect(notification_event_name(:failed_pipeline)).to match('Failed pipeline') } end end diff --git a/spec/helpers/profiles_helper_spec.rb b/spec/helpers/profiles_helper_spec.rb new file mode 100644 index 00000000000..b33b3f3a228 --- /dev/null +++ b/spec/helpers/profiles_helper_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +describe ProfilesHelper do + describe '#email_provider_label' do + it "returns nil for users without external email" do + user = create(:user) + allow(helper).to receive(:current_user).and_return(user) + + expect(helper.email_provider_label).to be_nil + end + + it "returns omniauth provider label for users with external email" do + stub_cas_omniauth_provider + cas_user = create(:omniauth_user, provider: 'cas3', external_email: true, email_provider: 'cas3') + allow(helper).to receive(:current_user).and_return(cas_user) + + expect(helper.email_provider_label).to eq('CAS') + end + + it "returns 'LDAP' for users with external email but no email provider" do + ldap_user = create(:omniauth_user, external_email: true) + allow(helper).to receive(:current_user).and_return(ldap_user) + + expect(helper.email_provider_label).to eq('LDAP') + end + end + + def stub_cas_omniauth_provider + provider = OpenStruct.new( + 'name' => 'cas3', + 'label' => 'CAS' + ) + + stub_omniauth_setting(providers: [provider]) + end +end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index be97973c693..a695621b87a 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -66,8 +66,8 @@ describe ProjectsHelper do describe "#project_list_cache_key", redis: true do let(:project) { create(:project) } - it "includes the namespace" do - expect(helper.project_list_cache_key(project)).to include(project.namespace.cache_key) + it "includes the route" do + expect(helper.project_list_cache_key(project)).to include(project.route.cache_key) end it "includes the project" do @@ -257,7 +257,7 @@ describe ProjectsHelper do 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[disabled]', text: "Everyone with access") end end @@ -272,7 +272,7 @@ describe ProjectsHelper do 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[disabled]', text: "Everyone with access") expect(result).to have_selector('option[selected]', text: "Only team members") end end diff --git a/spec/helpers/rss_helper_spec.rb b/spec/helpers/rss_helper_spec.rb index f3f174f3d14..269e1057e8d 100644 --- a/spec/helpers/rss_helper_spec.rb +++ b/spec/helpers/rss_helper_spec.rb @@ -3,17 +3,17 @@ require 'spec_helper' describe RssHelper do describe '#rss_url_options' do context 'when signed in' do - it "includes the current_user's private_token" do + it "includes the current_user's rss_token" do current_user = create(:user) allow(helper).to receive(:current_user).and_return(current_user) - expect(helper.rss_url_options).to include private_token: current_user.private_token + expect(helper.rss_url_options).to include rss_token: current_user.rss_token end end context 'when signed out' do - it "does not have a private_token" do + it "does not have an rss_token" do allow(helper).to receive(:current_user).and_return(nil) - expect(helper.rss_url_options[:private_token]).to be_nil + expect(helper.rss_url_options[:rss_token]).to be_nil end end end diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb index 9da33792659..cb727430117 100644 --- a/spec/helpers/submodule_helper_spec.rb +++ b/spec/helpers/submodule_helper_spec.rb @@ -52,6 +52,14 @@ describe SubmoduleHelper do stub_url(['http://', config.host, '/gitlab/root/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 'works with subgroups' 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)) + stub_url(['http://', config.host, '/gitlab/root/gitlab-org/sub/gitlab-ce.git'].join('')) + expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org/sub', 'gitlab-ce'), namespace_project_tree_path('gitlab-org/sub', 'gitlab-ce', 'hash')]) + end end context 'submodule on github.com' do @@ -81,6 +89,19 @@ describe SubmoduleHelper do end end + context 'in-repository submodule' do + let(:group) { create(:group, name: "Master Project", path: "master-project") } + let(:project) { create(:empty_project, group: group) } + before do + self.instance_variable_set(:@project, project) + end + + it 'in-repository' do + stub_url('./') + expect(submodule_links(submodule_item)).to eq(["/master-project/#{project.path}", "/master-project/#{project.path}/tree/hash"]) + end + end + context 'submodule on gitlab.com' do it 'detects ssh' do stub_url('git@gitlab.com:gitlab-org/gitlab-ce.git') @@ -102,6 +123,11 @@ describe SubmoduleHelper do 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 'handles urls with trailing whitespace' 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 'returns original with non-standard url' do stub_url('http://gitlab.com/another/gitlab-org/gitlab-ce.git') expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb index 8942b00b128..ad19cf9263d 100644 --- a/spec/helpers/visibility_level_helper_spec.rb +++ b/spec/helpers/visibility_level_helper_spec.rb @@ -37,7 +37,7 @@ describe VisibilityLevelHelper do it "describes public projects" do expect(project_visibility_level_description(Gitlab::VisibilityLevel::PUBLIC)) - .to eq "The project can be cloned without any authentication." + .to eq "The project can be accessed without any authentication." end end diff --git a/spec/javascripts/abuse_reports_spec.js b/spec/javascripts/abuse_reports_spec.js index 76b370b345b..069d857eab6 100644 --- a/spec/javascripts/abuse_reports_spec.js +++ b/spec/javascripts/abuse_reports_spec.js @@ -1,5 +1,5 @@ -require('~/lib/utils/text_utility'); -require('~/abuse_reports'); +import '~/lib/utils/text_utility'; +import '~/abuse_reports'; ((global) => { describe('Abuse Reports', () => { diff --git a/spec/javascripts/activities_spec.js b/spec/javascripts/activities_spec.js index e6a6fc36ca1..e8c5f721423 100644 --- a/spec/javascripts/activities_spec.js +++ b/spec/javascripts/activities_spec.js @@ -1,8 +1,8 @@ /* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow, max-len */ -require('vendor/jquery.endless-scroll.js'); -require('~/pager'); -require('~/activities'); +import 'vendor/jquery.endless-scroll'; +import '~/pager'; +import '~/activities'; (() => { window.gon || (window.gon = {}); diff --git a/spec/javascripts/ajax_loading_spinner_spec.js b/spec/javascripts/ajax_loading_spinner_spec.js index a68bccb16f4..1518ae68b0d 100644 --- a/spec/javascripts/ajax_loading_spinner_spec.js +++ b/spec/javascripts/ajax_loading_spinner_spec.js @@ -1,7 +1,7 @@ -require('~/extensions/array'); -require('jquery'); -require('jquery-ujs'); -require('~/ajax_loading_spinner'); +import '~/extensions/array'; +import 'jquery'; +import 'jquery-ujs'; +import '~/ajax_loading_spinner'; describe('Ajax Loading Spinner', () => { const fixtureTemplate = 'static/ajax_loading_spinner.html.raw'; diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js new file mode 100644 index 00000000000..867322ce8ae --- /dev/null +++ b/spec/javascripts/api_spec.js @@ -0,0 +1,281 @@ +import Api from '~/api'; + +describe('Api', () => { + const dummyApiVersion = 'v3000'; + const dummyUrlRoot = 'http://host.invalid'; + const dummyGon = { + api_version: dummyApiVersion, + relative_url_root: dummyUrlRoot, + }; + const dummyResponse = 'hello from outer space!'; + const sendDummyResponse = () => { + const deferred = $.Deferred(); + deferred.resolve(dummyResponse); + return deferred.promise(); + }; + let originalGon; + + beforeEach(() => { + originalGon = window.gon; + window.gon = dummyGon; + }); + + afterEach(() => { + window.gon = originalGon; + }); + + describe('buildUrl', () => { + it('adds URL root and fills in API version', () => { + const input = '/api/:version/foo/bar'; + const expectedOutput = `${dummyUrlRoot}/api/${dummyApiVersion}/foo/bar`; + + const builtUrl = Api.buildUrl(input); + + expect(builtUrl).toEqual(expectedOutput); + }); + }); + + describe('group', () => { + it('fetches a group', (done) => { + const groupId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}.json`; + spyOn(jQuery, 'ajax').and.callFake((request) => { + expect(request.url).toEqual(expectedUrl); + expect(request.dataType).toEqual('json'); + return sendDummyResponse(); + }); + + Api.group(groupId, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('groups', () => { + it('fetches groups', (done) => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups.json`; + const expectedData = Object.assign({ + search: query, + per_page: 20, + }, options); + spyOn(jQuery, 'ajax').and.callFake((request) => { + expect(request.url).toEqual(expectedUrl); + expect(request.dataType).toEqual('json'); + expect(request.data).toEqual(expectedData); + return sendDummyResponse(); + }); + + Api.groups(query, options, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('namespaces', () => { + it('fetches namespaces', (done) => { + const query = 'dummy query'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/namespaces.json`; + const expectedData = { + search: query, + per_page: 20, + }; + spyOn(jQuery, 'ajax').and.callFake((request) => { + expect(request.url).toEqual(expectedUrl); + expect(request.dataType).toEqual('json'); + expect(request.data).toEqual(expectedData); + return sendDummyResponse(); + }); + + Api.namespaces(query, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('projects', () => { + it('fetches projects', (done) => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`; + const expectedData = Object.assign({ + search: query, + per_page: 20, + membership: true, + }, options); + spyOn(jQuery, 'ajax').and.callFake((request) => { + expect(request.url).toEqual(expectedUrl); + expect(request.dataType).toEqual('json'); + expect(request.data).toEqual(expectedData); + return sendDummyResponse(); + }); + + Api.projects(query, options, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('newLabel', () => { + it('creates a new label', (done) => { + const namespace = 'some namespace'; + const project = 'some project'; + const labelData = { some: 'data' }; + const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/labels`; + const expectedData = { + label: labelData, + }; + spyOn(jQuery, 'ajax').and.callFake((request) => { + expect(request.url).toEqual(expectedUrl); + expect(request.dataType).toEqual('json'); + expect(request.type).toEqual('POST'); + expect(request.data).toEqual(expectedData); + return sendDummyResponse(); + }); + + Api.newLabel(namespace, project, labelData, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('groupProjects', () => { + it('fetches group projects', (done) => { + const groupId = '123456'; + const query = 'dummy query'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; + const expectedData = { + search: query, + per_page: 20, + }; + spyOn(jQuery, 'ajax').and.callFake((request) => { + expect(request.url).toEqual(expectedUrl); + expect(request.dataType).toEqual('json'); + expect(request.data).toEqual(expectedData); + return sendDummyResponse(); + }); + + Api.groupProjects(groupId, query, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('licenseText', () => { + it('fetches a license text', (done) => { + const licenseKey = "driver's license"; + const data = { unused: 'option' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/licenses/${licenseKey}`; + spyOn(jQuery, 'ajax').and.callFake((request) => { + expect(request.url).toEqual(expectedUrl); + expect(request.data).toEqual(data); + return sendDummyResponse(); + }); + + Api.licenseText(licenseKey, data, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('gitignoreText', () => { + it('fetches a gitignore text', (done) => { + const gitignoreKey = 'ignore git'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/gitignores/${gitignoreKey}`; + spyOn(jQuery, 'get').and.callFake((url, callback) => { + expect(url).toEqual(expectedUrl); + callback(dummyResponse); + }); + + Api.gitignoreText(gitignoreKey, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('gitlabCiYml', () => { + it('fetches a .gitlab-ci.yml', (done) => { + const gitlabCiYmlKey = 'Y CI ML'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/gitlab_ci_ymls/${gitlabCiYmlKey}`; + spyOn(jQuery, 'get').and.callFake((url, callback) => { + expect(url).toEqual(expectedUrl); + callback(dummyResponse); + }); + + Api.gitlabCiYml(gitlabCiYmlKey, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('dockerfileYml', () => { + it('fetches a Dockerfile', (done) => { + const dockerfileYmlKey = 'a giant whale'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/dockerfiles/${dockerfileYmlKey}`; + spyOn(jQuery, 'get').and.callFake((url, callback) => { + expect(url).toEqual(expectedUrl); + callback(dummyResponse); + }); + + Api.dockerfileYml(dockerfileYmlKey, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('issueTemplate', () => { + it('fetches an issue template', (done) => { + const namespace = 'some namespace'; + const project = 'some project'; + const templateKey = 'template key'; + const templateType = 'template type'; + const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}/${templateKey}`; + spyOn(jQuery, 'ajax').and.callFake((request) => { + expect(request.url).toEqual(expectedUrl); + return sendDummyResponse(); + }); + + Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => { + expect(error).toBe(null); + expect(response).toBe(dummyResponse); + done(); + }); + }); + }); + + describe('users', () => { + it('fetches users', (done) => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users.json`; + const expectedData = Object.assign({ + search: query, + per_page: 20, + }, options); + spyOn(jQuery, 'ajax').and.callFake((request) => { + expect(request.url).toEqual(expectedUrl); + expect(request.dataType).toEqual('json'); + expect(request.data).toEqual(expectedData); + return sendDummyResponse(); + }); + + Api.users(query, options) + .then((response) => { + expect(response).toBe(dummyResponse); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js index 68ad5f66676..3fc03324d16 100644 --- a/spec/javascripts/awards_handler_spec.js +++ b/spec/javascripts/awards_handler_spec.js @@ -3,7 +3,7 @@ import Cookies from 'js-cookie'; import AwardsHandler from '~/awards_handler'; -require('~/lib/utils/common_utils'); +import '~/lib/utils/common_utils'; (function() { var awardsHandler, lazyAssert, urlRoot, openAndWaitForEmojiMenu; diff --git a/spec/javascripts/behaviors/autosize_spec.js b/spec/javascripts/behaviors/autosize_spec.js index 3deaf258cae..67afba19190 100644 --- a/spec/javascripts/behaviors/autosize_spec.js +++ b/spec/javascripts/behaviors/autosize_spec.js @@ -1,6 +1,6 @@ /* eslint-disable space-before-function-paren, no-var, comma-dangle, no-return-assign, max-len */ -require('~/behaviors/autosize'); +import '~/behaviors/autosize'; (function() { describe('Autosize behavior', function() { diff --git a/spec/javascripts/behaviors/bind_in_out_spec.js b/spec/javascripts/behaviors/bind_in_out_spec.js index dd9ab33289f..5ff66167718 100644 --- a/spec/javascripts/behaviors/bind_in_out_spec.js +++ b/spec/javascripts/behaviors/bind_in_out_spec.js @@ -2,7 +2,7 @@ import BindInOut from '~/behaviors/bind_in_out'; import ClassSpecHelper from '../helpers/class_spec_helper'; describe('BindInOut', function () { - describe('.constructor', function () { + describe('constructor', function () { beforeEach(function () { this.in = {}; this.out = {}; @@ -53,7 +53,7 @@ describe('BindInOut', function () { }); }); - describe('.addEvents', function () { + describe('addEvents', function () { beforeEach(function () { this.in = jasmine.createSpyObj('in', ['addEventListener']); @@ -79,7 +79,7 @@ describe('BindInOut', function () { }); }); - describe('.updateOut', function () { + describe('updateOut', function () { beforeEach(function () { this.in = { value: 'the-value' }; this.out = { textContent: 'not-the-value' }; @@ -98,7 +98,7 @@ describe('BindInOut', function () { }); }); - describe('.removeEvents', function () { + describe('removeEvents', function () { beforeEach(function () { this.in = jasmine.createSpyObj('in', ['removeEventListener']); this.updateOut = () => {}; @@ -122,7 +122,7 @@ describe('BindInOut', function () { }); }); - describe('.initAll', function () { + describe('initAll', function () { beforeEach(function () { this.ins = [0, 1, 2]; this.instances = []; @@ -153,7 +153,7 @@ describe('BindInOut', function () { }); }); - describe('.init', function () { + describe('init', function () { beforeEach(function () { spyOn(BindInOut.prototype, 'addEvents').and.callFake(function () { return this; }); spyOn(BindInOut.prototype, 'updateOut').and.callFake(function () { return this; }); diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js index 4820ce41ade..f56b99f8a16 100644 --- a/spec/javascripts/behaviors/quick_submit_spec.js +++ b/spec/javascripts/behaviors/quick_submit_spec.js @@ -1,6 +1,6 @@ /* eslint-disable space-before-function-paren, no-var, no-return-assign, comma-dangle, jasmine/no-spec-dupes, new-cap, max-len */ -require('~/behaviors/quick_submit'); +import '~/behaviors/quick_submit'; (function() { describe('Quick Submit behavior', function() { diff --git a/spec/javascripts/behaviors/requires_input_spec.js b/spec/javascripts/behaviors/requires_input_spec.js index 3a84013a2ed..f9fa814b801 100644 --- a/spec/javascripts/behaviors/requires_input_spec.js +++ b/spec/javascripts/behaviors/requires_input_spec.js @@ -1,6 +1,6 @@ /* eslint-disable space-before-function-paren, no-var */ -require('~/behaviors/requires_input'); +import '~/behaviors/requires_input'; (function() { describe('requiresInput', function() { diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_integration_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_integration_spec.js new file mode 100644 index 00000000000..acd0aaf2a86 --- /dev/null +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_integration_spec.js @@ -0,0 +1,51 @@ +/* eslint-disable import/no-unresolved */ + +import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; +import bmprPath from '../../fixtures/blob/balsamiq/test.bmpr'; + +describe('Balsamiq integration spec', () => { + let container; + let endpoint; + let balsamiqViewer; + + preloadFixtures('static/balsamiq_viewer.html.raw'); + + beforeEach(() => { + loadFixtures('static/balsamiq_viewer.html.raw'); + + container = document.getElementById('js-balsamiq-viewer'); + balsamiqViewer = new BalsamiqViewer(container); + }); + + describe('successful response', () => { + beforeEach((done) => { + endpoint = bmprPath; + + balsamiqViewer.loadFile(endpoint).then(done).catch(done.fail); + }); + + it('does not show loading icon', () => { + expect(document.querySelector('.loading')).toBeNull(); + }); + + it('renders the balsamiq previews', () => { + expect(document.querySelectorAll('.previews .preview').length).not.toEqual(0); + }); + }); + + describe('error getting file', () => { + beforeEach((done) => { + endpoint = 'invalid/path/to/file.bmpr'; + + balsamiqViewer.loadFile(endpoint).then(done.fail, null).catch(done); + }); + + it('does not show loading icon', () => { + expect(document.querySelector('.loading')).toBeNull(); + }); + + it('does not render the balsamiq previews', () => { + expect(document.querySelectorAll('.previews .preview').length).toEqual(0); + }); + }); +}); diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js index 85816ee1f11..aa87956109f 100644 --- a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js @@ -4,17 +4,11 @@ import ClassSpecHelper from '../../helpers/class_spec_helper'; describe('BalsamiqViewer', () => { let balsamiqViewer; - let endpoint; let viewer; describe('class constructor', () => { beforeEach(() => { - endpoint = 'endpoint'; - viewer = { - dataset: { - endpoint, - }, - }; + viewer = {}; balsamiqViewer = new BalsamiqViewer(viewer); }); @@ -22,25 +16,25 @@ describe('BalsamiqViewer', () => { it('should set .viewer', () => { expect(balsamiqViewer.viewer).toBe(viewer); }); + }); + + describe('fileLoaded', () => { - it('should set .endpoint', () => { - expect(balsamiqViewer.endpoint).toBe(endpoint); - }); }); describe('loadFile', () => { let xhr; + let loadFile; + const endpoint = 'endpoint'; beforeEach(() => { - endpoint = 'endpoint'; xhr = jasmine.createSpyObj('xhr', ['open', 'send']); balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderFile']); - balsamiqViewer.endpoint = endpoint; spyOn(window, 'XMLHttpRequest').and.returnValue(xhr); - BalsamiqViewer.prototype.loadFile.call(balsamiqViewer); + loadFile = BalsamiqViewer.prototype.loadFile.call(balsamiqViewer, endpoint); }); it('should call .open', () => { @@ -54,6 +48,10 @@ describe('BalsamiqViewer', () => { it('should call .send', () => { expect(xhr.send).toHaveBeenCalled(); }); + + it('should return a promise', () => { + expect(loadFile).toEqual(jasmine.any(Promise)); + }); }); describe('renderFile', () => { @@ -325,18 +323,4 @@ describe('BalsamiqViewer', () => { expect(parseTitle).toBe('name'); }); }); - - describe('onError', () => { - beforeEach(() => { - spyOn(window, 'Flash'); - - BalsamiqViewer.onError(); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'onError'); - - it('should instantiate Flash', () => { - expect(window.Flash).toHaveBeenCalledWith('Balsamiq file could not be loaded.'); - }); - }); }); diff --git a/spec/javascripts/blob/create_branch_dropdown_spec.js b/spec/javascripts/blob/create_branch_dropdown_spec.js index c1179e572ae..6dbaa47c544 100644 --- a/spec/javascripts/blob/create_branch_dropdown_spec.js +++ b/spec/javascripts/blob/create_branch_dropdown_spec.js @@ -1,7 +1,6 @@ -require('~/gl_dropdown'); -require('~/lib/utils/type_utility'); -require('~/blob/create_branch_dropdown'); -require('~/blob/target_branch_dropdown'); +import '~/gl_dropdown'; +import '~/blob/create_branch_dropdown'; +import '~/blob/target_branch_dropdown'; describe('CreateBranchDropdown', () => { const fixtureTemplate = 'static/target_branch_dropdown.html.raw'; diff --git a/spec/javascripts/blob/target_branch_dropdown_spec.js b/spec/javascripts/blob/target_branch_dropdown_spec.js index 4fb79663c51..99c9537d2ec 100644 --- a/spec/javascripts/blob/target_branch_dropdown_spec.js +++ b/spec/javascripts/blob/target_branch_dropdown_spec.js @@ -1,7 +1,6 @@ -require('~/gl_dropdown'); -require('~/lib/utils/type_utility'); -require('~/blob/create_branch_dropdown'); -require('~/blob/target_branch_dropdown'); +import '~/gl_dropdown'; +import '~/blob/create_branch_dropdown'; +import '~/blob/target_branch_dropdown'; describe('TargetBranchDropdown', () => { const fixtureTemplate = 'static/target_branch_dropdown.html.raw'; @@ -63,7 +62,7 @@ describe('TargetBranchDropdown', () => { expect('change.branch').toHaveBeenTriggeredOn(dropdown.$dropdown); }); - describe('#dropdownData', () => { + describe('dropdownData', () => { it('cache the refs', () => { const refs = dropdown.cachedRefs; dropdown.cachedRefs = null; @@ -88,7 +87,7 @@ describe('TargetBranchDropdown', () => { }); }); - describe('#setNewBranch', () => { + describe('setNewBranch', () => { it('adds the new branch and select it', () => { const branchName = 'new_branch'; diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js index 376e706d1db..447b244c71f 100644 --- a/spec/javascripts/boards/board_card_spec.js +++ b/spec/javascripts/boards/board_card_spec.js @@ -8,11 +8,11 @@ import Vue from 'vue'; import '~/boards/models/assignee'; -require('~/boards/models/list'); -require('~/boards/models/label'); -require('~/boards/stores/boards_store'); -const boardCard = require('~/boards/components/board_card').default; -require('./mock_data'); +import '~/boards/models/list'; +import '~/boards/models/label'; +import '~/boards/stores/boards_store'; +import boardCard from '~/boards/components/board_card'; +import './mock_data'; describe('Issue card', () => { let vm; diff --git a/spec/javascripts/boards/board_new_issue_spec.js b/spec/javascripts/boards/board_new_issue_spec.js index 4999933c0c1..832877de71c 100644 --- a/spec/javascripts/boards/board_new_issue_spec.js +++ b/spec/javascripts/boards/board_new_issue_spec.js @@ -6,8 +6,8 @@ import Vue from 'vue'; import boardNewIssue from '~/boards/components/board_new_issue'; -require('~/boards/models/list'); -require('./mock_data'); +import '~/boards/models/list'; +import './mock_data'; describe('Issue boards new issue form', () => { let vm; @@ -19,6 +19,7 @@ describe('Issue boards new issue form', () => { }; }, }; + const submitIssue = () => { vm.$el.querySelector('.btn-success').click(); }; @@ -107,7 +108,7 @@ describe('Issue boards new issue form', () => { setTimeout(() => { submitIssue(); - expect(vm.$el.querySelector('.btn-success').disbled).not.toBe(true); + expect(vm.$el.querySelector('.btn-success').disabled).toBe(false); done(); }, 0); }); @@ -115,36 +116,43 @@ describe('Issue boards new issue form', () => { it('clears title after submit', (done) => { vm.title = 'submit issue'; - setTimeout(() => { + Vue.nextTick(() => { submitIssue(); - expect(vm.title).toBe(''); - done(); - }, 0); + setTimeout(() => { + expect(vm.title).toBe(''); + done(); + }, 0); + }); }); - it('adds new issue to list after submit', (done) => { + it('adds new issue to top of list after submit request', (done) => { vm.title = 'submit issue'; setTimeout(() => { submitIssue(); - expect(list.issues.length).toBe(2); - expect(list.issues[1].title).toBe('submit issue'); - expect(list.issues[1].subscribed).toBe(true); - done(); + setTimeout(() => { + expect(list.issues.length).toBe(2); + expect(list.issues[0].title).toBe('submit issue'); + expect(list.issues[0].subscribed).toBe(true); + done(); + }, 0); }, 0); }); it('sets detail issue after submit', (done) => { + expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe(undefined); vm.title = 'submit issue'; setTimeout(() => { submitIssue(); - expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe('submit issue'); - done(); - }); + setTimeout(() => { + expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe('submit issue'); + done(); + }, 0); + }, 0); }); it('sets detail list after submit', (done) => { @@ -153,8 +161,10 @@ describe('Issue boards new issue form', () => { setTimeout(() => { submitIssue(); - expect(gl.issueBoards.BoardsStore.detail.list.id).toBe(list.id); - done(); + setTimeout(() => { + expect(gl.issueBoards.BoardsStore.detail.list.id).toBe(list.id); + done(); + }, 0); }, 0); }); }); @@ -169,13 +179,12 @@ describe('Issue boards new issue form', () => { setTimeout(() => { expect(list.issues.length).toBe(1); done(); - }, 500); + }, 0); }, 0); }); it('shows error', (done) => { vm.title = 'error'; - submitIssue(); setTimeout(() => { submitIssue(); @@ -183,7 +192,7 @@ describe('Issue boards new issue form', () => { setTimeout(() => { expect(vm.error).toBe(true); done(); - }, 500); + }, 0); }, 0); }); }); diff --git a/spec/javascripts/boards/components/board_spec.js b/spec/javascripts/boards/components/board_spec.js new file mode 100644 index 00000000000..c4e8966ad6c --- /dev/null +++ b/spec/javascripts/boards/components/board_spec.js @@ -0,0 +1,112 @@ +import Vue from 'vue'; +import '~/boards/services/board_service'; +import '~/boards/components/board'; +import '~/boards/models/list'; + +describe('Board component', () => { + let vm; + let el; + + beforeEach((done) => { + loadFixtures('boards/show.html.raw'); + + el = document.createElement('div'); + document.body.appendChild(el); + + // eslint-disable-next-line no-undef + gl.boardService = new BoardService('/', '/', 1); + + vm = new gl.issueBoards.Board({ + propsData: { + boardId: '1', + disabled: false, + issueLinkBase: '/', + rootPath: '/', + // eslint-disable-next-line no-undef + list: new List({ + id: 1, + position: 0, + title: 'test', + list_type: 'backlog', + }), + }, + }).$mount(el); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + // remove the component from the DOM + document.querySelector('.board').remove(); + + localStorage.removeItem(`boards.${vm.boardId}.${vm.list.type}.expanded`); + }); + + it('board is expandable when list type is backlog', () => { + expect( + vm.$el.classList.contains('is-expandable'), + ).toBe(true); + }); + + it('board is expandable when list type is closed', (done) => { + vm.list.type = 'closed'; + + Vue.nextTick(() => { + expect( + vm.$el.classList.contains('is-expandable'), + ).toBe(true); + + done(); + }); + }); + + it('board is not expandable when list type is label', (done) => { + vm.list.type = 'label'; + vm.list.isExpandable = false; + + Vue.nextTick(() => { + expect( + vm.$el.classList.contains('is-expandable'), + ).toBe(false); + + done(); + }); + }); + + it('collapses when clicking header', (done) => { + vm.$el.querySelector('.board-header').click(); + + Vue.nextTick(() => { + expect( + vm.$el.classList.contains('is-collapsed'), + ).toBe(true); + + done(); + }); + }); + + it('created sets isExpanded to true from localStorage', (done) => { + vm.$el.querySelector('.board-header').click(); + + return Vue.nextTick() + .then(() => { + expect( + vm.$el.classList.contains('is-collapsed'), + ).toBe(true); + + // call created manually + vm.$options.created[0].call(vm); + + return Vue.nextTick(); + }) + .then(() => { + expect( + vm.$el.classList.contains('is-collapsed'), + ).toBe(true); + + done(); + }); + }); +}); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index fddde799d01..bd9b4fbfdd3 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -129,7 +129,7 @@ describe('Issue card component', () => { it('sets title', () => { expect( - component.$el.querySelector('.card-assignee a').getAttribute('title'), + component.$el.querySelector('.card-assignee img').getAttribute('data-original-title'), ).toContain(`Assigned to ${user.name}`); }); diff --git a/spec/javascripts/build_spec.js b/spec/javascripts/build_spec.js index 8ec96bdb583..4c8a48580d7 100644 --- a/spec/javascripts/build_spec.js +++ b/spec/javascripts/build_spec.js @@ -8,13 +8,12 @@ import '~/breakpoints'; import 'vendor/jquery.nicescroll'; describe('Build', () => { - const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1`; + const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1`; preloadFixtures('builds/build-with-artifacts.html.raw'); beforeEach(() => { loadFixtures('builds/build-with-artifacts.html.raw'); - spyOn($, 'ajax'); }); describe('class constructor', () => { @@ -33,7 +32,6 @@ describe('Build', () => { it('copies build options', function () { expect(this.build.pageUrl).toBe(BUILD_URL); - expect(this.build.buildUrl).toBe(`${BUILD_URL}.json`); expect(this.build.buildStatus).toBe('success'); expect(this.build.buildStage).toBe('test'); expect(this.build.state).toBe(''); @@ -60,32 +58,19 @@ describe('Build', () => { it('displays the remove date correctly', () => { const removeDateElement = document.querySelector('.js-artifacts-remove'); - expect(removeDateElement.innerText.trim()).toBe('1 year'); + expect(removeDateElement.innerText.trim()).toBe('1 year remaining'); }); }); describe('running build', () => { - beforeEach(function () { - this.build = new Build(); - }); - it('updates the build trace on an interval', function () { + const deferred1 = $.Deferred(); + const deferred2 = $.Deferred(); + const deferred3 = $.Deferred(); + spyOn($, 'ajax').and.returnValues(deferred1.promise(), deferred2.promise(), deferred3.promise()); spyOn(gl.utils, 'visitUrl'); - jasmine.clock().tick(4001); - - expect($.ajax.calls.count()).toBe(1); - - // We have to do it this way to prevent Webpack to fail to compile - // when destructuring assignments and reusing - // the same variables names inside the same scope - let args = $.ajax.calls.argsFor(0)[0]; - - expect(args.url).toBe(`${BUILD_URL}/trace.json`); - expect(args.dataType).toBe('json'); - expect(args.success).toEqual(jasmine.any(Function)); - - args.success.call($, { + deferred1.resolve({ html: '<span>Update<span>', status: 'running', state: 'newstate', @@ -93,20 +78,9 @@ describe('Build', () => { complete: false, }); - expect($('#build-trace .js-build-output').text()).toMatch(/Update/); - expect(this.build.state).toBe('newstate'); - - jasmine.clock().tick(4001); - - expect($.ajax.calls.count()).toBe(3); - - args = $.ajax.calls.argsFor(2)[0]; - expect(args.url).toBe(`${BUILD_URL}/trace.json`); - expect(args.dataType).toBe('json'); - expect(args.data.state).toBe('newstate'); - expect(args.success).toEqual(jasmine.any(Function)); + deferred2.resolve(); - args.success.call($, { + deferred3.resolve({ html: '<span>More</span>', status: 'running', state: 'finalstate', @@ -114,150 +88,222 @@ describe('Build', () => { complete: true, }); + this.build = new Build(); + + expect($('#build-trace .js-build-output').text()).toMatch(/Update/); + expect(this.build.state).toBe('newstate'); + + jasmine.clock().tick(4001); + expect($('#build-trace .js-build-output').text()).toMatch(/UpdateMore/); expect(this.build.state).toBe('finalstate'); }); it('replaces the entire build trace', () => { + const deferred1 = $.Deferred(); + const deferred2 = $.Deferred(); + const deferred3 = $.Deferred(); + + spyOn($, 'ajax').and.returnValues(deferred1.promise(), deferred2.promise(), deferred3.promise()); + spyOn(gl.utils, 'visitUrl'); - jasmine.clock().tick(4001); - let args = $.ajax.calls.argsFor(0)[0]; - args.success.call($, { - html: '<span>Update</span>', + deferred1.resolve({ + html: '<span>Update<span>', status: 'running', append: false, complete: false, }); - expect($('#build-trace .js-build-output').text()).toMatch(/Update/); + deferred2.resolve(); - jasmine.clock().tick(4001); - args = $.ajax.calls.argsFor(2)[0]; - args.success.call($, { + deferred3.resolve({ html: '<span>Different</span>', status: 'running', append: false, }); + this.build = new Build(); + + expect($('#build-trace .js-build-output').text()).toMatch(/Update/); + + jasmine.clock().tick(4001); + expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/); expect($('#build-trace .js-build-output').text()).toMatch(/Different/); }); it('reloads the page when the build is done', () => { spyOn(gl.utils, 'visitUrl'); + const deferred = $.Deferred(); - jasmine.clock().tick(4001); - const [{ success }] = $.ajax.calls.argsFor(0); - success.call($, { + spyOn($, 'ajax').and.returnValue(deferred.promise()); + deferred.resolve({ html: '<span>Final</span>', status: 'passed', append: true, complete: true, }); + this.build = new Build(); + expect(gl.utils.visitUrl).toHaveBeenCalledWith(BUILD_URL); }); + }); - describe('truncated information', () => { - describe('when size is less than total', () => { - it('shows information about truncated log', () => { - jasmine.clock().tick(4001); - const [{ success }] = $.ajax.calls.argsFor(0); - - success.call($, { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 50, - total: 100, - }); - - expect(document.querySelector('.js-truncated-info').classList).not.toContain('hidden'); + describe('truncated information', () => { + describe('when size is less than total', () => { + it('shows information about truncated log', () => { + spyOn(gl.utils, 'visitUrl'); + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + deferred.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size: 50, + total: 100, }); - it('shows the size in KiB', () => { - jasmine.clock().tick(4001); - const [{ success }] = $.ajax.calls.argsFor(0); - const size = 50; - - success.call($, { - html: '<span>Update</span>', - status: 'success', - append: false, - size, - total: 100, - }); - - expect( - document.querySelector('.js-truncated-info-size').textContent.trim(), - ).toEqual(`${bytesToKiB(size)}`); + this.build = new Build(); + + expect(document.querySelector('.js-truncated-info').classList).not.toContain('hidden'); + }); + + it('shows the size in KiB', () => { + const size = 50; + spyOn(gl.utils, 'visitUrl'); + const deferred = $.Deferred(); + + spyOn($, 'ajax').and.returnValue(deferred.promise()); + deferred.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size, + total: 100, }); - it('shows incremented size', () => { - jasmine.clock().tick(4001); - let args = $.ajax.calls.argsFor(0)[0]; - args.success.call($, { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 50, - total: 100, - }); - - expect( - document.querySelector('.js-truncated-info-size').textContent.trim(), - ).toEqual(`${bytesToKiB(50)}`); - - jasmine.clock().tick(4001); - args = $.ajax.calls.argsFor(2)[0]; - args.success.call($, { - html: '<span>Update</span>', - status: 'success', - append: true, - size: 10, - total: 100, - }); - - expect( - document.querySelector('.js-truncated-info-size').textContent.trim(), - ).toEqual(`${bytesToKiB(60)}`); + this.build = new Build(); + + expect( + document.querySelector('.js-truncated-info-size').textContent.trim(), + ).toEqual(`${bytesToKiB(size)}`); + }); + + it('shows incremented size', () => { + const deferred1 = $.Deferred(); + const deferred2 = $.Deferred(); + const deferred3 = $.Deferred(); + + spyOn($, 'ajax').and.returnValues(deferred1.promise(), deferred2.promise(), deferred3.promise()); + + spyOn(gl.utils, 'visitUrl'); + + deferred1.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size: 50, + total: 100, }); - it('renders the raw link', () => { - jasmine.clock().tick(4001); - const [{ success }] = $.ajax.calls.argsFor(0); - - success.call($, { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 50, - total: 100, - }); - - expect( - document.querySelector('.js-raw-link').textContent.trim(), - ).toContain('Complete Raw'); + deferred2.resolve(); + + this.build = new Build(); + + expect( + document.querySelector('.js-truncated-info-size').textContent.trim(), + ).toEqual(`${bytesToKiB(50)}`); + + jasmine.clock().tick(4001); + + deferred3.resolve({ + html: '<span>Update</span>', + status: 'success', + append: true, + size: 10, + total: 100, }); + + expect( + document.querySelector('.js-truncated-info-size').textContent.trim(), + ).toEqual(`${bytesToKiB(60)}`); }); - describe('when size is equal than total', () => { - it('does not show the trunctated information', () => { - jasmine.clock().tick(4001); - const [{ success }] = $.ajax.calls.argsFor(0); + it('renders the raw link', () => { + const deferred = $.Deferred(); + spyOn(gl.utils, 'visitUrl'); + + spyOn($, 'ajax').and.returnValue(deferred.promise()); + deferred.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size: 50, + total: 100, + }); - success.call($, { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 100, - total: 100, - }); + this.build = new Build(); - expect(document.querySelector('.js-truncated-info').classList).toContain('hidden'); + expect( + document.querySelector('.js-raw-link').textContent.trim(), + ).toContain('Complete Raw'); + }); + }); + + describe('when size is equal than total', () => { + it('does not show the trunctated information', () => { + const deferred = $.Deferred(); + spyOn(gl.utils, 'visitUrl'); + + spyOn($, 'ajax').and.returnValue(deferred.promise()); + deferred.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size: 100, + total: 100, }); + + this.build = new Build(); + + expect(document.querySelector('.js-truncated-info').classList).toContain('hidden'); + }); + }); + }); + + describe('output trace', () => { + beforeEach(() => { + const deferred = $.Deferred(); + spyOn(gl.utils, 'visitUrl'); + + spyOn($, 'ajax').and.returnValue(deferred.promise()); + deferred.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size: 50, + total: 100, }); + + this.build = new Build(); + }); + + it('should render trace controls', () => { + const controllers = document.querySelector('.controllers'); + + expect(controllers.querySelector('.js-raw-link-controller')).toBeDefined(); + expect(controllers.querySelector('.js-erase-link')).toBeDefined(); + expect(controllers.querySelector('.js-scroll-up')).toBeDefined(); + expect(controllers.querySelector('.js-scroll-down')).toBeDefined(); + }); + + it('should render received output', () => { + expect( + document.querySelector('.js-build-output').innerHTML, + ).toEqual('<span>Update</span>'); }); }); }); diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js b/spec/javascripts/commit/pipelines/pipelines_spec.js index ad31448f81c..ebfd60198b2 100644 --- a/spec/javascripts/commit/pipelines/pipelines_spec.js +++ b/spec/javascripts/commit/pipelines/pipelines_spec.js @@ -1,12 +1,17 @@ import Vue from 'vue'; import PipelinesTable from '~/commit/pipelines/pipelines_table'; -import pipeline from './mock_data'; describe('Pipelines table in Commits and Merge requests', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + let pipeline; + preloadFixtures('static/pipelines_table.html.raw'); + preloadFixtures(jsonFixtureName); beforeEach(() => { loadFixtures('static/pipelines_table.html.raw'); + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); }); describe('successful request', () => { @@ -66,7 +71,7 @@ describe('Pipelines table in Commits and Merge requests', () => { it('should render a table with the received pipelines', (done) => { setTimeout(() => { - expect(this.component.$el.querySelectorAll('table > tbody > tr').length).toEqual(1); + expect(this.component.$el.querySelectorAll('.ci-table .commit').length).toEqual(1); expect(this.component.$el.querySelector('.realtime-loading')).toBe(null); expect(this.component.$el.querySelector('.empty-state')).toBe(null); expect(this.component.$el.querySelector('.js-pipelines-error-state')).toBe(null); @@ -103,7 +108,7 @@ describe('Pipelines table in Commits and Merge requests', () => { expect(this.component.$el.querySelector('.js-pipelines-error-state')).toBeDefined(); expect(this.component.$el.querySelector('.realtime-loading')).toBe(null); expect(this.component.$el.querySelector('.js-empty-state')).toBe(null); - expect(this.component.$el.querySelector('table')).toBe(null); + expect(this.component.$el.querySelector('.ci-table')).toBe(null); done(); }, 0); }); diff --git a/spec/javascripts/commits_spec.js b/spec/javascripts/commits_spec.js index 05260760c43..44a4386b250 100644 --- a/spec/javascripts/commits_spec.js +++ b/spec/javascripts/commits_spec.js @@ -1,8 +1,8 @@ /* global CommitsList */ -require('vendor/jquery.endless-scroll'); -require('~/pager'); -require('~/commits'); +import 'vendor/jquery.endless-scroll'; +import '~/pager'; +import '~/commits'; (() => { // TODO: remove this hack! @@ -28,6 +28,32 @@ require('~/commits'); expect(CommitsList).toBeDefined(); }); + describe('processCommits', () => { + it('should join commit headers', () => { + CommitsList.$contentList = $(` + <div> + <li class="commit-header" data-day="2016-09-20"> + <span class="day">20 Sep, 2016</span> + <span class="commits-count">1 commit</span> + </li> + <li class="commit"></li> + </div> + `); + + const data = ` + <li class="commit-header" data-day="2016-09-20"> + <span class="day">20 Sep, 2016</span> + <span class="commits-count">1 commit</span> + </li> + <li class="commit"></li> + `; + + // The last commit header should be removed + // since the previous one has the same data-day value. + expect(CommitsList.processCommits(data).find('li.commit-header').length).toBe(0); + }); + }); + describe('on entering input', () => { let ajaxSpy; diff --git a/spec/javascripts/copy_as_gfm_spec.js b/spec/javascripts/copy_as_gfm_spec.js new file mode 100644 index 00000000000..ded450749d3 --- /dev/null +++ b/spec/javascripts/copy_as_gfm_spec.js @@ -0,0 +1,49 @@ +import '~/copy_as_gfm'; + +(() => { + describe('gl.CopyAsGFM', () => { + describe('gl.CopyAsGFM.pasteGFM', () => { + function callPasteGFM() { + const e = { + originalEvent: { + clipboardData: { + getData(mimeType) { + // When GFM code is copied, we put the regular plain text + // on the clipboard as `text/plain`, and the GFM as `text/x-gfm`. + // This emulates the behavior of `getData` with that data. + if (mimeType === 'text/plain') { + return 'code'; + } + if (mimeType === 'text/x-gfm') { + return '`code`'; + } + return null; + }, + }, + }, + preventDefault() {}, + }; + + window.gl.CopyAsGFM.pasteGFM(e); + } + + it('wraps pasted code when not already in code tags', () => { + spyOn(window.gl.utils, 'insertText').and.callFake((el, textFunc) => { + const insertedText = textFunc('This is code: ', ''); + expect(insertedText).toEqual('`code`'); + }); + + callPasteGFM(); + }); + + it('does not wrap pasted code when already in code tags', () => { + spyOn(window.gl.utils, 'insertText').and.callFake((el, textFunc) => { + const insertedText = textFunc('This is code: `', '`'); + expect(insertedText).toEqual('code'); + }); + + callPasteGFM(); + }); + }); + }); +})(); diff --git a/spec/javascripts/datetime_utility_spec.js b/spec/javascripts/datetime_utility_spec.js index d5eec10be42..c82ad0bea48 100644 --- a/spec/javascripts/datetime_utility_spec.js +++ b/spec/javascripts/datetime_utility_spec.js @@ -1,7 +1,27 @@ -require('~/lib/utils/datetime_utility'); +import '~/lib/utils/datetime_utility'; (() => { describe('Date time utils', () => { + describe('timeFor', () => { + it('returns `past due` when in past', () => { + const date = new Date(); + date.setFullYear(date.getFullYear() - 1); + + expect( + gl.utils.timeFor(date), + ).toBe('Past due'); + }); + + it('returns remaining time when in the future', () => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 1); + + expect( + gl.utils.timeFor(date), + ).toBe('1 year remaining'); + }); + }); + describe('get day name', () => { it('should return Sunday', () => { const day = gl.utils.getDayName(new Date('07/17/2016')); diff --git a/spec/javascripts/deploy_keys/components/key_spec.js b/spec/javascripts/deploy_keys/components/key_spec.js index 793ab8c451d..a4b98f6140d 100644 --- a/spec/javascripts/deploy_keys/components/key_spec.js +++ b/spec/javascripts/deploy_keys/components/key_spec.js @@ -39,9 +39,15 @@ describe('Deploy keys key', () => { ).toBe(`created ${gl.utils.getTimeago().format(deployKey.created_at)}`); }); + it('shows edit button', () => { + expect( + vm.$el.querySelectorAll('.btn')[0].textContent.trim(), + ).toBe('Edit'); + }); + it('shows remove button', () => { expect( - vm.$el.querySelector('.btn').textContent.trim(), + vm.$el.querySelectorAll('.btn')[1].textContent.trim(), ).toBe('Remove'); }); @@ -71,9 +77,15 @@ describe('Deploy keys key', () => { setTimeout(done); }); + it('shows edit button', () => { + expect( + vm.$el.querySelectorAll('.btn')[0].textContent.trim(), + ).toBe('Edit'); + }); + it('shows enable button', () => { expect( - vm.$el.querySelector('.btn').textContent.trim(), + vm.$el.querySelectorAll('.btn')[1].textContent.trim(), ).toBe('Enable'); }); @@ -82,7 +94,7 @@ describe('Deploy keys key', () => { Vue.nextTick(() => { expect( - vm.$el.querySelector('.btn').textContent.trim(), + vm.$el.querySelectorAll('.btn')[1].textContent.trim(), ).toBe('Disable'); done(); diff --git a/spec/javascripts/diff_comments_store_spec.js b/spec/javascripts/diff_comments_store_spec.js index 66ece7e4f41..d6fc6b56b82 100644 --- a/spec/javascripts/diff_comments_store_spec.js +++ b/spec/javascripts/diff_comments_store_spec.js @@ -1,9 +1,9 @@ /* eslint-disable jasmine/no-global-setup, dot-notation, jasmine/no-expect-in-setup-teardown, max-len */ /* global CommentsStore */ -require('~/diff_notes/models/discussion'); -require('~/diff_notes/models/note'); -require('~/diff_notes/stores/comments'); +import '~/diff_notes/models/discussion'; +import '~/diff_notes/models/note'; +import '~/diff_notes/stores/comments'; function createDiscussion(noteId = 1, resolved = true) { CommentsStore.create({ diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js index e7786e8cc2c..2bbcebeeac0 100644 --- a/spec/javascripts/droplab/drop_down_spec.js +++ b/spec/javascripts/droplab/drop_down_spec.js @@ -1,5 +1,3 @@ -/* eslint-disable */ - import DropDown from '~/droplab/drop_down'; import utils from '~/droplab/utils'; import { SELECTED_CLASS, IGNORE_CLASS } from '~/droplab/constants'; @@ -17,7 +15,7 @@ describe('DropDown', function () { it('sets the .hidden property to true', function () { expect(this.dropdown.hidden).toBe(true); - }) + }); it('sets the .list property', function () { expect(this.dropdown.list).toBe(this.list); @@ -152,7 +150,7 @@ describe('DropDown', function () { it('should call addSelectedClass', function () { expect(this.dropdown.addSelectedClass).toHaveBeenCalledWith(this.closestElement); - }) + }); it('should call .preventDefault', function () { expect(this.event.preventDefault).toHaveBeenCalled(); @@ -293,36 +291,6 @@ describe('DropDown', function () { }); }); - describe('toggle', function () { - beforeEach(function () { - this.dropdown = { hidden: true, show: () => {}, hide: () => {} }; - - spyOn(this.dropdown, 'show'); - spyOn(this.dropdown, 'hide'); - - DropDown.prototype.toggle.call(this.dropdown); - }); - - it('should call .show if hidden is true', function () { - expect(this.dropdown.show).toHaveBeenCalled(); - }); - - describe('if hidden is false', function () { - beforeEach(function () { - this.dropdown = { hidden: false, show: () => {}, hide: () => {} }; - - spyOn(this.dropdown, 'show'); - spyOn(this.dropdown, 'hide'); - - DropDown.prototype.toggle.call(this.dropdown); - }); - - it('should call .show if hidden is true', function () { - expect(this.dropdown.hide).toHaveBeenCalled(); - }); - }); - }); - describe('setData', function () { beforeEach(function () { this.dropdown = { render: () => {} }; @@ -399,7 +367,7 @@ describe('DropDown', function () { expect(this.data.map).toHaveBeenCalledWith(jasmine.any(Function)); }); - it('should call .renderChildren for each data item', function() { + it('should call .renderChildren for each data item', function () { expect(this.dropdown.renderChildren.calls.count()).toBe(this.data.length); }); @@ -407,7 +375,7 @@ describe('DropDown', function () { expect(this.renderableList.innerHTML).toBe('01'); }); - describe('if no data argument is passed' , function () { + describe('if no data argument is passed', function () { beforeEach(function () { this.data.map.calls.reset(); this.dropdown.renderChildren.calls.reset(); @@ -446,14 +414,14 @@ describe('DropDown', function () { describe('renderChildren', function () { beforeEach(function () { this.templateString = 'templateString'; - this.dropdown = { setImagesSrc: () => {}, templateString: this.templateString }; + this.dropdown = { templateString: this.templateString }; this.data = { droplab_hidden: true }; this.html = 'html'; this.template = { firstChild: { outerHTML: 'outerHTML', style: {} } }; spyOn(utils, 'template').and.returnValue(this.html); spyOn(document, 'createElement').and.returnValue(this.template); - spyOn(this.dropdown, 'setImagesSrc'); + spyOn(DropDown, 'setImagesSrc'); this.renderChildren = DropDown.prototype.renderChildren.call(this.dropdown, this.data); }); @@ -471,7 +439,7 @@ describe('DropDown', function () { }); it('should call .setImagesSrc with the template', function () { - expect(this.dropdown.setImagesSrc).toHaveBeenCalledWith(this.template); + expect(DropDown.setImagesSrc).toHaveBeenCalledWith(this.template); }); it('should set the template display to none', function () { @@ -496,12 +464,11 @@ describe('DropDown', function () { describe('setImagesSrc', function () { beforeEach(function () { - this.dropdown = {}; this.template = { querySelectorAll: () => {} }; spyOn(this.template, 'querySelectorAll').and.returnValue([]); - DropDown.prototype.setImagesSrc.call(this.dropdown, this.template); + DropDown.setImagesSrc(this.template); }); it('should call .querySelectorAll', function () { @@ -562,7 +529,7 @@ describe('DropDown', function () { describe('toggle', function () { beforeEach(function () { - this.hidden = true + this.hidden = true; this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} }; spyOn(this.dropdown, 'show'); @@ -577,7 +544,7 @@ describe('DropDown', function () { describe('if .hidden is false', function () { beforeEach(function () { - this.hidden = false + this.hidden = false; this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} }; spyOn(this.dropdown, 'show'); diff --git a/spec/javascripts/droplab/hook_spec.js b/spec/javascripts/droplab/hook_spec.js index 8ebdcdd1404..75bf5f3d611 100644 --- a/spec/javascripts/droplab/hook_spec.js +++ b/spec/javascripts/droplab/hook_spec.js @@ -1,5 +1,3 @@ -/* eslint-disable */ - import Hook from '~/droplab/hook'; import * as dropdownSrc from '~/droplab/drop_down'; @@ -73,10 +71,4 @@ describe('Hook', function () { }); }); }); - - describe('addEvents', function () { - it('should exist', function () { - expect(Hook.prototype.hasOwnProperty('addEvents')).toBe(true); - }); - }); }); diff --git a/spec/javascripts/droplab/plugins/ajax_filter_spec.js b/spec/javascripts/droplab/plugins/ajax_filter_spec.js new file mode 100644 index 00000000000..8155d98b543 --- /dev/null +++ b/spec/javascripts/droplab/plugins/ajax_filter_spec.js @@ -0,0 +1,72 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; +import AjaxFilter from '~/droplab/plugins/ajax_filter'; + +describe('AjaxFilter', () => { + let dummyConfig; + const dummyData = 'dummy data'; + let dummyList; + + beforeEach(() => { + dummyConfig = { + endpoint: 'dummy endpoint', + searchKey: 'dummy search key', + }; + dummyList = { + data: [], + list: document.createElement('div'), + }; + + AjaxFilter.hook = { + config: { + AjaxFilter: dummyConfig, + }, + list: dummyList, + }; + }); + + describe('trigger', () => { + let ajaxSpy; + + beforeEach(() => { + spyOn(AjaxCache, 'retrieve').and.callFake(url => ajaxSpy(url)); + spyOn(AjaxFilter, '_loadData'); + + dummyConfig.onLoadingFinished = jasmine.createSpy('spy'); + + const dynamicList = document.createElement('div'); + dynamicList.dataset.dynamic = true; + dummyList.list.appendChild(dynamicList); + }); + + it('calls onLoadingFinished after loading data', (done) => { + ajaxSpy = (url) => { + expect(url).toBe('dummy endpoint?dummy search key='); + return Promise.resolve(dummyData); + }; + + AjaxFilter.trigger() + .then(() => { + expect(dummyConfig.onLoadingFinished.calls.count()).toBe(1); + }) + .then(done) + .catch(done.fail); + }); + + it('does not call onLoadingFinished if Ajax call fails', (done) => { + const dummyError = new Error('My dummy is sick! :-('); + ajaxSpy = (url) => { + expect(url).toBe('dummy endpoint?dummy search key='); + return Promise.reject(dummyError); + }; + + AjaxFilter.trigger() + .then(done.fail) + .catch((error) => { + expect(error).toBe(dummyError); + expect(dummyConfig.onLoadingFinished.calls.count()).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/environments/environment_spec.js b/spec/javascripts/environments/environment_spec.js index 1c54cc3054c..6639a6b5e7b 100644 --- a/spec/javascripts/environments/environment_spec.js +++ b/spec/javascripts/environments/environment_spec.js @@ -41,7 +41,7 @@ describe('Environment', () => { setTimeout(() => { expect( component.$el.querySelector('.js-new-environment-button').textContent, - ).toContain('New Environment'); + ).toContain('New environment'); expect( component.$el.querySelector('.js-blank-state-title').textContent, @@ -271,7 +271,7 @@ describe('Environment', () => { // wait for next async request setTimeout(() => { expect(component.$el.querySelectorAll('.js-child-row').length).toEqual(1); - expect(component.$el.querySelector('td.text-center > a.btn').textContent).toContain('Show all'); + expect(component.$el.querySelector('.text-center > a.btn').textContent).toContain('Show all'); Vue.http.interceptors = _.without(Vue.http.interceptors, folderInterceptor); done(); diff --git a/spec/javascripts/environments/environment_table_spec.js b/spec/javascripts/environments/environment_table_spec.js index effbc6c3ee1..2862971bec4 100644 --- a/spec/javascripts/environments/environment_table_spec.js +++ b/spec/javascripts/environments/environment_table_spec.js @@ -29,6 +29,6 @@ describe('Environment item', () => { }, }).$mount(); - expect(component.$el.tagName).toEqual('TABLE'); + expect(component.$el.getAttribute('class')).toContain('ci-table'); }); }); diff --git a/spec/javascripts/environments/environments_store_spec.js b/spec/javascripts/environments/environments_store_spec.js index f617c4bdffe..6e855530b21 100644 --- a/spec/javascripts/environments/environments_store_spec.js +++ b/spec/javascripts/environments/environments_store_spec.js @@ -123,4 +123,13 @@ describe('Store', () => { expect(store.state.paginationInformation).toEqual(expectedResult); }); }); + + describe('getOpenFolders', () => { + it('should return open folder', () => { + store.storeEnvironments(serverData); + + store.toggleFolder(store.state.environments[1]); + expect(store.getOpenFolders()[0]).toEqual(store.state.environments[1]); + }); + }); }); diff --git a/spec/javascripts/extensions/array_spec.js b/spec/javascripts/extensions/array_spec.js index 4b871fe967d..b1b81b4efc2 100644 --- a/spec/javascripts/extensions/array_spec.js +++ b/spec/javascripts/extensions/array_spec.js @@ -1,6 +1,6 @@ /* eslint-disable space-before-function-paren, no-var */ -require('~/extensions/array'); +import '~/extensions/array'; (function() { describe('Array extensions', function() { diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js index d0f09a561d5..79447787fc9 100644 --- a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js +++ b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -2,6 +2,8 @@ import Vue from 'vue'; import eventHub from '~/filtered_search/event_hub'; import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content'; +import '~/filtered_search/filtered_search_token_keys'; + const createComponent = (propsData) => { const Component = Vue.extend(RecentSearchesDropdownContent); @@ -17,12 +19,14 @@ const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim(); describe('RecentSearchesDropdownContent', () => { const propsDataWithoutItems = { items: [], + allowedKeys: gl.FilteredSearchTokenKeys.getKeys(), }; const propsDataWithItems = { items: [ 'foo', 'author:@root label:~foo bar', ], + allowedKeys: gl.FilteredSearchTokenKeys.getKeys(), }; let vm; diff --git a/spec/javascripts/filtered_search/dropdown_user_spec.js b/spec/javascripts/filtered_search/dropdown_user_spec.js index 3f92fe4701e..f7708301b6e 100644 --- a/spec/javascripts/filtered_search/dropdown_user_spec.js +++ b/spec/javascripts/filtered_search/dropdown_user_spec.js @@ -1,7 +1,7 @@ -require('~/filtered_search/dropdown_utils'); -require('~/filtered_search/filtered_search_tokenizer'); -require('~/filtered_search/filtered_search_dropdown'); -require('~/filtered_search/dropdown_user'); +import '~/filtered_search/dropdown_utils'; +import '~/filtered_search/filtered_search_tokenizer'; +import '~/filtered_search/filtered_search_dropdown'; +import '~/filtered_search/dropdown_user'; describe('Dropdown User', () => { describe('getSearchInput', () => { @@ -12,7 +12,7 @@ describe('Dropdown User', () => { spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {}); spyOn(gl.DropdownUtils, 'getSearchInput').and.callFake(() => {}); - dropdownUser = new gl.DropdownUser(); + dropdownUser = new gl.DropdownUser(null, null, null, gl.FilteredSearchTokenKeys); }); it('should not return the double quote found in value', () => { diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js b/spec/javascripts/filtered_search/dropdown_utils_spec.js index c820c955172..f55726379f3 100644 --- a/spec/javascripts/filtered_search/dropdown_utils_spec.js +++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js @@ -1,9 +1,13 @@ -require('~/extensions/array'); -require('~/filtered_search/dropdown_utils'); -require('~/filtered_search/filtered_search_tokenizer'); -require('~/filtered_search/filtered_search_dropdown_manager'); +import '~/extensions/array'; +import '~/filtered_search/dropdown_utils'; +import '~/filtered_search/filtered_search_tokenizer'; +import '~/filtered_search/filtered_search_dropdown_manager'; +import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; describe('Dropdown Utils', () => { + const issueListFixture = 'issues/issue_list.html.raw'; + preloadFixtures(issueListFixture); + describe('getEscapedText', () => { it('should return same word when it has no space', () => { const escaped = gl.DropdownUtils.getEscapedText('textWithoutSpace'); @@ -122,6 +126,7 @@ describe('Dropdown Utils', () => { describe('filterHint', () => { let input; + let allowedKeys; beforeEach(() => { setFixtures(` @@ -133,30 +138,38 @@ describe('Dropdown Utils', () => { `); input = document.getElementById('test'); + allowedKeys = gl.FilteredSearchTokenKeys.getKeys(); }); + function config() { + return { + input, + allowedKeys, + }; + } + it('should filter', () => { input.value = 'l'; - let updatedItem = gl.DropdownUtils.filterHint(input, { + let updatedItem = gl.DropdownUtils.filterHint(config(), { hint: 'label', }); expect(updatedItem.droplab_hidden).toBe(false); input.value = 'o'; - updatedItem = gl.DropdownUtils.filterHint(input, { + updatedItem = gl.DropdownUtils.filterHint(config(), { hint: 'label', }); expect(updatedItem.droplab_hidden).toBe(true); }); it('should return droplab_hidden false when item has no hint', () => { - const updatedItem = gl.DropdownUtils.filterHint(input, {}, ''); + const updatedItem = gl.DropdownUtils.filterHint(config(), {}, ''); expect(updatedItem.droplab_hidden).toBe(false); }); it('should allow multiple if item.type is array', () => { input.value = 'label:~first la'; - const updatedItem = gl.DropdownUtils.filterHint(input, { + const updatedItem = gl.DropdownUtils.filterHint(config(), { hint: 'label', type: 'array', }); @@ -165,12 +178,12 @@ describe('Dropdown Utils', () => { it('should prevent multiple if item.type is not array', () => { input.value = 'milestone:~first mile'; - let updatedItem = gl.DropdownUtils.filterHint(input, { + let updatedItem = gl.DropdownUtils.filterHint(config(), { hint: 'milestone', }); expect(updatedItem.droplab_hidden).toBe(true); - updatedItem = gl.DropdownUtils.filterHint(input, { + updatedItem = gl.DropdownUtils.filterHint(config(), { hint: 'milestone', type: 'string', }); @@ -305,4 +318,29 @@ describe('Dropdown Utils', () => { }); }); }); + + describe('getSearchQuery', () => { + let authorToken; + + beforeEach(() => { + loadFixtures(issueListFixture); + + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); + const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term'); + + const tokensContainer = document.querySelector('.tokens-container'); + tokensContainer.appendChild(searchTermToken); + tokensContainer.appendChild(authorToken); + }); + + it('uses original value if present', () => { + const originalValue = 'original dance'; + const valueContainer = authorToken.querySelector('.value-container'); + valueContainer.dataset.originalValue = originalValue; + + const searchQuery = gl.DropdownUtils.getSearchQuery(); + + expect(searchQuery).toBe(' search term author:original dance'); + }); + }); }); diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js index 17bf8932489..c92a147b937 100644 --- a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js @@ -1,7 +1,7 @@ -require('~/extensions/array'); -require('~/filtered_search/filtered_search_visual_tokens'); -require('~/filtered_search/filtered_search_tokenizer'); -require('~/filtered_search/filtered_search_dropdown_manager'); +import '~/extensions/array'; +import '~/filtered_search/filtered_search_visual_tokens'; +import '~/filtered_search/filtered_search_tokenizer'; +import '~/filtered_search/filtered_search_dropdown_manager'; describe('Filtered Search Dropdown Manager', () => { describe('addWordToInput', () => { diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index 063d547d00c..6d00d71f145 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -1,14 +1,13 @@ import * as recentSearchesStoreSrc from '~/filtered_search/stores/recent_searches_store'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; - -require('~/lib/utils/url_utility'); -require('~/lib/utils/common_utils'); -require('~/filtered_search/filtered_search_token_keys'); -require('~/filtered_search/filtered_search_tokenizer'); -require('~/filtered_search/filtered_search_dropdown_manager'); -require('~/filtered_search/filtered_search_manager'); -const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper'); +import '~/lib/utils/url_utility'; +import '~/lib/utils/common_utils'; +import '~/filtered_search/filtered_search_token_keys'; +import '~/filtered_search/filtered_search_tokenizer'; +import '~/filtered_search/filtered_search_dropdown_manager'; +import '~/filtered_search/filtered_search_manager'; +import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; describe('Filtered Search Manager', () => { let input; @@ -58,6 +57,7 @@ describe('Filtered Search Manager', () => { input = document.querySelector('.filtered-search'); tokensContainer = document.querySelector('.tokens-container'); manager = new gl.FilteredSearchManager(); + manager.setup(); }); afterEach(() => { @@ -73,6 +73,7 @@ describe('Filtered Search Manager', () => { spyOn(recentSearchesStoreSrc, 'default'); filteredSearchManager = new gl.FilteredSearchManager(); + filteredSearchManager.setup(); return filteredSearchManager; }); @@ -81,6 +82,7 @@ describe('Filtered Search Manager', () => { expect(RecentSearchesService.isAvailable).toHaveBeenCalled(); expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({ isLocalStorageAvailable, + allowedKeys: gl.FilteredSearchTokenKeys.getKeys(), }); }); @@ -89,11 +91,55 @@ describe('Filtered Search Manager', () => { spyOn(window, 'Flash'); filteredSearchManager = new gl.FilteredSearchManager(); + filteredSearchManager.setup(); expect(window.Flash).not.toHaveBeenCalled(); }); }); + describe('searchState', () => { + beforeEach(() => { + spyOn(gl.FilteredSearchManager.prototype, 'search').and.callFake(() => {}); + }); + + it('should blur button', () => { + const e = { + currentTarget: { + blur: () => {}, + }, + }; + spyOn(e.currentTarget, 'blur').and.callThrough(); + manager.searchState(e); + + expect(e.currentTarget.blur).toHaveBeenCalled(); + }); + + it('should not call search if there is no state', () => { + const e = { + currentTarget: { + blur: () => {}, + }, + }; + + manager.searchState(e); + expect(gl.FilteredSearchManager.prototype.search).not.toHaveBeenCalled(); + }); + + it('should call search when there is state', () => { + const e = { + currentTarget: { + blur: () => {}, + dataset: { + state: 'opened', + }, + }, + }; + + manager.searchState(e); + expect(gl.FilteredSearchManager.prototype.search).toHaveBeenCalledWith('opened'); + }); + }); + describe('search', () => { const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened'; @@ -313,42 +359,6 @@ describe('Filtered Search Manager', () => { }); }); - describe('unselects token', () => { - beforeEach(() => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug', true)} - ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} - `); - }); - - it('unselects token when input is clicked', () => { - const selectedToken = tokensContainer.querySelector('.js-visual-token .selected'); - - expect(selectedToken.classList.contains('selected')).toEqual(true); - expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled(); - - // Click directly on input attached to document - // so that the click event will propagate properly - document.querySelector('.filtered-search').click(); - - expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled(); - expect(selectedToken.classList.contains('selected')).toEqual(false); - }); - - it('unselects token when document.body is clicked', () => { - const selectedToken = tokensContainer.querySelector('.js-visual-token .selected'); - - expect(selectedToken.classList.contains('selected')).toEqual(true); - expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled(); - - document.body.click(); - - expect(selectedToken.classList.contains('selected')).toEqual(false); - expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled(); - }); - }); - describe('toggleInputContainerFocus', () => { it('toggles on focus', () => { input.focus(); diff --git a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js index 6f9fa434c35..1a7631994b4 100644 --- a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js @@ -1,5 +1,5 @@ -require('~/extensions/array'); -require('~/filtered_search/filtered_search_token_keys'); +import '~/extensions/array'; +import '~/filtered_search/filtered_search_token_keys'; describe('Filtered Search Token Keys', () => { describe('get', () => { diff --git a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js index 3e2e577f115..e4a15c83c23 100644 --- a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js @@ -1,11 +1,13 @@ -require('~/extensions/array'); -require('~/filtered_search/filtered_search_token_keys'); -require('~/filtered_search/filtered_search_tokenizer'); +import '~/extensions/array'; +import '~/filtered_search/filtered_search_token_keys'; +import '~/filtered_search/filtered_search_tokenizer'; describe('Filtered Search Tokenizer', () => { + const allowedKeys = gl.FilteredSearchTokenKeys.getKeys(); + describe('processTokens', () => { it('returns for input containing only search value', () => { - const results = gl.FilteredSearchTokenizer.processTokens('searchTerm'); + const results = gl.FilteredSearchTokenizer.processTokens('searchTerm', allowedKeys); expect(results.searchToken).toBe('searchTerm'); expect(results.tokens.length).toBe(0); expect(results.lastToken).toBe(results.searchToken); @@ -13,7 +15,7 @@ describe('Filtered Search Tokenizer', () => { it('returns for input containing only tokens', () => { const results = gl.FilteredSearchTokenizer - .processTokens('author:@root label:~"Very Important" milestone:%v1.0 assignee:none'); + .processTokens('author:@root label:~"Very Important" milestone:%v1.0 assignee:none', allowedKeys); expect(results.searchToken).toBe(''); expect(results.tokens.length).toBe(4); expect(results.tokens[3]).toBe(results.lastToken); @@ -37,7 +39,7 @@ describe('Filtered Search Tokenizer', () => { it('returns for input starting with search value and ending with tokens', () => { const results = gl.FilteredSearchTokenizer - .processTokens('searchTerm anotherSearchTerm milestone:none'); + .processTokens('searchTerm anotherSearchTerm milestone:none', allowedKeys); expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); expect(results.tokens.length).toBe(1); expect(results.tokens[0]).toBe(results.lastToken); @@ -48,7 +50,7 @@ describe('Filtered Search Tokenizer', () => { it('returns for input starting with tokens and ending with search value', () => { const results = gl.FilteredSearchTokenizer - .processTokens('assignee:@user searchTerm'); + .processTokens('assignee:@user searchTerm', allowedKeys); expect(results.searchToken).toBe('searchTerm'); expect(results.tokens.length).toBe(1); @@ -60,7 +62,7 @@ describe('Filtered Search Tokenizer', () => { it('returns for input containing search value wrapped between tokens', () => { const results = gl.FilteredSearchTokenizer - .processTokens('author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none'); + .processTokens('author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none', allowedKeys); expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); expect(results.tokens.length).toBe(3); @@ -81,7 +83,7 @@ describe('Filtered Search Tokenizer', () => { it('returns for input containing search value in between tokens', () => { const results = gl.FilteredSearchTokenizer - .processTokens('author:@root searchTerm assignee:none anotherSearchTerm label:~Doing'); + .processTokens('author:@root searchTerm assignee:none anotherSearchTerm label:~Doing', allowedKeys); expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); expect(results.tokens.length).toBe(3); expect(results.tokens[2]).toBe(results.lastToken); @@ -100,14 +102,14 @@ describe('Filtered Search Tokenizer', () => { }); it('returns search value for invalid tokens', () => { - const results = gl.FilteredSearchTokenizer.processTokens('fake:token'); + const results = gl.FilteredSearchTokenizer.processTokens('fake:token', allowedKeys); expect(results.lastToken).toBe('fake:token'); expect(results.searchToken).toBe('fake:token'); expect(results.tokens.length).toEqual(0); }); it('returns search value and token for mix of valid and invalid tokens', () => { - const results = gl.FilteredSearchTokenizer.processTokens('label:real fake:token'); + const results = gl.FilteredSearchTokenizer.processTokens('label:real fake:token', allowedKeys); expect(results.tokens.length).toEqual(1); expect(results.tokens[0].key).toBe('label'); expect(results.tokens[0].value).toBe('real'); @@ -117,13 +119,13 @@ describe('Filtered Search Tokenizer', () => { }); it('returns search value for invalid symbols', () => { - const results = gl.FilteredSearchTokenizer.processTokens('std::includes'); + const results = gl.FilteredSearchTokenizer.processTokens('std::includes', allowedKeys); expect(results.lastToken).toBe('std::includes'); expect(results.searchToken).toBe('std::includes'); }); it('removes duplicated values', () => { - const results = gl.FilteredSearchTokenizer.processTokens('label:~foo label:~foo'); + const results = gl.FilteredSearchTokenizer.processTokens('label:~foo label:~foo', allowedKeys); expect(results.tokens.length).toBe(1); expect(results.tokens[0].key).toBe('label'); expect(results.tokens[0].value).toBe('foo'); diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js index 8b750561eb7..fa4343ffbc8 100644 --- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js @@ -1,10 +1,22 @@ import AjaxCache from '~/lib/utils/ajax_cache'; +import UsersCache from '~/lib/utils/users_cache'; -require('~/filtered_search/filtered_search_visual_tokens'); -const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper'); +import '~/filtered_search/filtered_search_visual_tokens'; +import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; describe('Filtered Search Visual Tokens', () => { + const subject = gl.FilteredSearchVisualTokens; + + const findElements = (tokenElement) => { + const tokenNameElement = tokenElement.querySelector('.name'); + const tokenValueContainer = tokenElement.querySelector('.value-container'); + const tokenValueElement = tokenValueContainer.querySelector('.value'); + return { tokenNameElement, tokenValueContainer, tokenValueElement }; + }; + let tokensContainer; + let authorToken; + let bugLabelToken; beforeEach(() => { setFixtures(` @@ -13,12 +25,15 @@ describe('Filtered Search Visual Tokens', () => { </ul> `); tokensContainer = document.querySelector('.tokens-container'); + + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); + bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); }); describe('getLastVisualTokenBeforeInput', () => { it('returns when there are no visual tokens', () => { const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); expect(lastVisualToken).toEqual(null); expect(isLastVisualTokenValid).toEqual(true); @@ -27,11 +42,11 @@ describe('Filtered Search Visual Tokens', () => { describe('input is the last item in tokensContainer', () => { it('returns when there is one visual token', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + bugLabelToken.outerHTML, ); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); expect(isLastVisualTokenValid).toEqual(true); @@ -43,7 +58,7 @@ describe('Filtered Search Visual Tokens', () => { ); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); expect(isLastVisualTokenValid).toEqual(false); @@ -51,13 +66,13 @@ describe('Filtered Search Visual Tokens', () => { it('returns when there are multiple visual tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} `); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); const items = document.querySelectorAll('.tokens-container .js-visual-token'); expect(lastVisualToken.isEqualNode(items[items.length - 1])).toEqual(true); @@ -66,13 +81,13 @@ describe('Filtered Search Visual Tokens', () => { it('returns when there are multiple visual tokens and an incomplete visual token', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee')} `); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); const items = document.querySelectorAll('.tokens-container .js-visual-token'); expect(lastVisualToken.isEqualNode(items[items.length - 1])).toEqual(true); @@ -83,13 +98,13 @@ describe('Filtered Search Visual Tokens', () => { describe('input is a middle item in tokensContainer', () => { it('returns last token before input', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createInputHTML()} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} `); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); expect(isLastVisualTokenValid).toEqual(true); @@ -103,7 +118,7 @@ describe('Filtered Search Visual Tokens', () => { `); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); expect(isLastVisualTokenValid).toEqual(false); @@ -114,7 +129,7 @@ describe('Filtered Search Visual Tokens', () => { describe('unselectTokens', () => { it('does nothing when there are no tokens', () => { const beforeHTML = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.unselectTokens(); + subject.unselectTokens(); expect(tokensContainer.innerHTML).toEqual(beforeHTML); }); @@ -128,7 +143,7 @@ describe('Filtered Search Visual Tokens', () => { const selected = tokensContainer.querySelector('.js-visual-token .selected'); expect(selected.classList.contains('selected')).toEqual(true); - gl.FilteredSearchVisualTokens.unselectTokens(); + subject.unselectTokens(); expect(selected.classList.contains('selected')).toEqual(false); }); @@ -137,7 +152,7 @@ describe('Filtered Search Visual Tokens', () => { describe('selectToken', () => { beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} `); @@ -147,7 +162,7 @@ describe('Filtered Search Visual Tokens', () => { const firstTokenButton = tokensContainer.querySelector('.js-visual-token .selectable'); firstTokenButton.classList.add('selected'); - gl.FilteredSearchVisualTokens.selectToken(firstTokenButton); + subject.selectToken(firstTokenButton); expect(firstTokenButton.classList.contains('selected')).toEqual(false); }); @@ -156,7 +171,7 @@ describe('Filtered Search Visual Tokens', () => { it('adds selected class', () => { const firstTokenButton = tokensContainer.querySelector('.js-visual-token .selectable'); - gl.FilteredSearchVisualTokens.selectToken(firstTokenButton); + subject.selectToken(firstTokenButton); expect(firstTokenButton.classList.contains('selected')).toEqual(true); }); @@ -165,7 +180,7 @@ describe('Filtered Search Visual Tokens', () => { const tokenButtons = tokensContainer.querySelectorAll('.js-visual-token .selectable'); tokenButtons[1].classList.add('selected'); - gl.FilteredSearchVisualTokens.selectToken(tokenButtons[0]); + subject.selectToken(tokenButtons[0]); expect(tokenButtons[0].classList.contains('selected')).toEqual(true); expect(tokenButtons[1].classList.contains('selected')).toEqual(false); @@ -181,7 +196,7 @@ describe('Filtered Search Visual Tokens', () => { expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); - gl.FilteredSearchVisualTokens.removeSelectedToken(); + subject.removeSelectedToken(); expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); }); @@ -193,7 +208,7 @@ describe('Filtered Search Visual Tokens', () => { expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); - gl.FilteredSearchVisualTokens.removeSelectedToken(); + subject.removeSelectedToken(); expect(tokensContainer.querySelector('.js-visual-token .selectable')).toEqual(null); }); @@ -205,7 +220,7 @@ describe('Filtered Search Visual Tokens', () => { beforeEach(() => { setFixtures(` <div class="test-area"> - ${gl.FilteredSearchVisualTokens.createVisualTokenElementHTML()} + ${subject.createVisualTokenElementHTML()} </div> `); @@ -245,7 +260,7 @@ describe('Filtered Search Visual Tokens', () => { describe('addVisualTokenElement', () => { it('renders search visual tokens', () => { - gl.FilteredSearchVisualTokens.addVisualTokenElement('search term', null, true); + subject.addVisualTokenElement('search term', null, true); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-term')).toEqual(true); @@ -254,7 +269,7 @@ describe('Filtered Search Visual Tokens', () => { }); it('renders filter visual token name', () => { - gl.FilteredSearchVisualTokens.addVisualTokenElement('milestone'); + subject.addVisualTokenElement('milestone'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); @@ -263,7 +278,7 @@ describe('Filtered Search Visual Tokens', () => { }); it('renders filter visual token name and value', () => { - gl.FilteredSearchVisualTokens.addVisualTokenElement('label', 'Frontend'); + subject.addVisualTokenElement('label', 'Frontend'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); @@ -274,7 +289,7 @@ describe('Filtered Search Visual Tokens', () => { it('inserts visual token before input', () => { tokensContainer.appendChild(FilteredSearchSpecHelper.createFilterVisualToken('assignee', '@root')); - gl.FilteredSearchVisualTokens.addVisualTokenElement('label', 'Frontend'); + subject.addVisualTokenElement('label', 'Frontend'); const tokens = tokensContainer.querySelectorAll('.js-visual-token'); const labelToken = tokens[0]; const assigneeToken = tokens[1]; @@ -296,7 +311,7 @@ describe('Filtered Search Visual Tokens', () => { ); const original = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + subject.addValueToPreviousVisualTokenElement('value'); expect(original).toEqual(tokensContainer.innerHTML); }); @@ -308,7 +323,7 @@ describe('Filtered Search Visual Tokens', () => { `); const original = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + subject.addValueToPreviousVisualTokenElement('value'); expect(original).toEqual(tokensContainer.innerHTML); }); @@ -319,7 +334,7 @@ describe('Filtered Search Visual Tokens', () => { ); const original = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + subject.addValueToPreviousVisualTokenElement('value'); const updatedToken = tokensContainer.querySelector('.js-visual-token'); expect(updatedToken.querySelector('.name').innerText).toEqual('label'); @@ -330,7 +345,7 @@ describe('Filtered Search Visual Tokens', () => { describe('addFilterVisualToken', () => { it('creates visual token with just tokenName', () => { - gl.FilteredSearchVisualTokens.addFilterVisualToken('milestone'); + subject.addFilterVisualToken('milestone'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); @@ -339,8 +354,8 @@ describe('Filtered Search Visual Tokens', () => { }); it('creates visual token with just tokenValue', () => { - gl.FilteredSearchVisualTokens.addFilterVisualToken('milestone'); - gl.FilteredSearchVisualTokens.addFilterVisualToken('%8.17'); + subject.addFilterVisualToken('milestone'); + subject.addFilterVisualToken('%8.17'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); @@ -349,7 +364,7 @@ describe('Filtered Search Visual Tokens', () => { }); it('creates full visual token', () => { - gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', '@john'); + subject.addFilterVisualToken('assignee', '@john'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); @@ -360,7 +375,7 @@ describe('Filtered Search Visual Tokens', () => { describe('addSearchVisualToken', () => { it('creates search visual token', () => { - gl.FilteredSearchVisualTokens.addSearchVisualToken('search term'); + subject.addSearchVisualToken('search term'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-term')).toEqual(true); @@ -374,7 +389,7 @@ describe('Filtered Search Visual Tokens', () => { ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} `); - gl.FilteredSearchVisualTokens.addSearchVisualToken('append this'); + subject.addSearchVisualToken('append this'); const token = tokensContainer.querySelector('.filtered-search-term'); expect(token.querySelector('.name').innerText).toEqual('search term append this'); @@ -386,10 +401,26 @@ describe('Filtered Search Visual Tokens', () => { it('should get last token value', () => { const value = '~bug'; tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', value), + bugLabelToken.outerHTML, + ); + + expect(subject.getLastTokenPartial()).toEqual(value); + }); + + it('should get last token original value if available', () => { + const originalValue = '@user'; + const valueContainer = authorToken.querySelector('.value-container'); + valueContainer.dataset.originalValue = originalValue; + const avatar = document.createElement('img'); + const valueElement = valueContainer.querySelector('.value'); + valueElement.insertAdjacentElement('afterbegin', avatar); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + authorToken.outerHTML, ); - expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(value); + const lastTokenValue = subject.getLastTokenPartial(); + + expect(lastTokenValue).toEqual(originalValue); }); it('should get last token name if there is no value', () => { @@ -398,11 +429,11 @@ describe('Filtered Search Visual Tokens', () => { FilteredSearchSpecHelper.createNameFilterVisualTokenHTML(name), ); - expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(name); + expect(subject.getLastTokenPartial()).toEqual(name); }); it('should return empty when there are no tokens', () => { - expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(''); + expect(subject.getLastTokenPartial()).toEqual(''); }); }); @@ -414,7 +445,7 @@ describe('Filtered Search Visual Tokens', () => { expect(tokensContainer.querySelector('.js-visual-token .value')).not.toEqual(null); - gl.FilteredSearchVisualTokens.removeLastTokenPartial(); + subject.removeLastTokenPartial(); expect(tokensContainer.querySelector('.js-visual-token .value')).toEqual(null); }); @@ -426,14 +457,14 @@ describe('Filtered Search Visual Tokens', () => { expect(tokensContainer.querySelector('.js-visual-token .name')).not.toEqual(null); - gl.FilteredSearchVisualTokens.removeLastTokenPartial(); + subject.removeLastTokenPartial(); expect(tokensContainer.querySelector('.js-visual-token .name')).toEqual(null); }); it('should not remove anything when there are no tokens', () => { const html = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.removeLastTokenPartial(); + subject.removeLastTokenPartial(); expect(tokensContainer.innerHTML).toEqual(html); }); @@ -442,7 +473,7 @@ describe('Filtered Search Visual Tokens', () => { describe('tokenizeInput', () => { it('does not do anything if there is no input', () => { const original = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.tokenizeInput(); + subject.tokenizeInput(); expect(tokensContainer.innerHTML).toEqual(original); }); @@ -454,7 +485,7 @@ describe('Filtered Search Visual Tokens', () => { const input = document.querySelector('.filtered-search'); input.value = 'some value'; - gl.FilteredSearchVisualTokens.tokenizeInput(); + subject.tokenizeInput(); const newToken = tokensContainer.querySelector('.filtered-search-term'); @@ -470,7 +501,7 @@ describe('Filtered Search Visual Tokens', () => { const input = document.querySelector('.filtered-search'); input.value = '@john'; - gl.FilteredSearchVisualTokens.tokenizeInput(); + subject.tokenizeInput(); const updatedToken = tokensContainer.querySelector('.filtered-search-token'); @@ -497,29 +528,39 @@ describe('Filtered Search Visual Tokens', () => { it('tokenize\'s existing input', () => { input.value = 'some text'; - spyOn(gl.FilteredSearchVisualTokens, 'tokenizeInput').and.callThrough(); + spyOn(subject, 'tokenizeInput').and.callThrough(); - gl.FilteredSearchVisualTokens.editToken(token); + subject.editToken(token); - expect(gl.FilteredSearchVisualTokens.tokenizeInput).toHaveBeenCalled(); + expect(subject.tokenizeInput).toHaveBeenCalled(); expect(input.value).not.toEqual('some text'); }); it('moves input to the token position', () => { expect(tokensContainer.children[3].querySelector('.filtered-search')).not.toEqual(null); - gl.FilteredSearchVisualTokens.editToken(token); + subject.editToken(token); expect(tokensContainer.children[1].querySelector('.filtered-search')).not.toEqual(null); expect(tokensContainer.children[3].querySelector('.filtered-search')).toEqual(null); }); it('input contains the visual token value', () => { - gl.FilteredSearchVisualTokens.editToken(token); + subject.editToken(token); expect(input.value).toEqual('none'); }); + it('input contains the original value if present', () => { + const originalValue = '@user'; + const valueContainer = token.querySelector('.value-container'); + valueContainer.dataset.originalValue = originalValue; + + subject.editToken(token); + + expect(input.value).toEqual(originalValue); + }); + describe('selected token is a search term token', () => { beforeEach(() => { token = document.querySelector('.filtered-search-term'); @@ -528,7 +569,7 @@ describe('Filtered Search Visual Tokens', () => { it('token is removed', () => { expect(tokensContainer.querySelector('.filtered-search-term')).not.toEqual(null); - gl.FilteredSearchVisualTokens.editToken(token); + subject.editToken(token); expect(tokensContainer.querySelector('.filtered-search-term')).toEqual(null); }); @@ -536,7 +577,7 @@ describe('Filtered Search Visual Tokens', () => { it('input has the same value as removed token', () => { expect(input.value).toEqual(''); - gl.FilteredSearchVisualTokens.editToken(token); + subject.editToken(token); expect(input.value).toEqual('search'); }); @@ -549,25 +590,25 @@ describe('Filtered Search Visual Tokens', () => { FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none'), ); - spyOn(gl.FilteredSearchVisualTokens, 'tokenizeInput').and.callFake(() => {}); - spyOn(gl.FilteredSearchVisualTokens, 'getLastVisualTokenBeforeInput').and.callThrough(); + spyOn(subject, 'tokenizeInput').and.callFake(() => {}); + spyOn(subject, 'getLastVisualTokenBeforeInput').and.callThrough(); - gl.FilteredSearchVisualTokens.moveInputToTheRight(); + subject.moveInputToTheRight(); - expect(gl.FilteredSearchVisualTokens.tokenizeInput).toHaveBeenCalled(); - expect(gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput).not.toHaveBeenCalled(); + expect(subject.tokenizeInput).toHaveBeenCalled(); + expect(subject.getLastVisualTokenBeforeInput).not.toHaveBeenCalled(); }); it('tokenize\'s input', () => { tokensContainer.innerHTML = ` ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} ${FilteredSearchSpecHelper.createInputHTML()} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} `; document.querySelector('.filtered-search').value = 'none'; - gl.FilteredSearchVisualTokens.moveInputToTheRight(); + subject.moveInputToTheRight(); const value = tokensContainer.querySelector('.js-visual-token .value'); expect(value.innerText).toEqual('none'); @@ -577,12 +618,12 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = ` ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} ${FilteredSearchSpecHelper.createInputHTML()} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} `; document.querySelector('.filtered-search').value = 'test'; - gl.FilteredSearchVisualTokens.moveInputToTheRight(); + subject.moveInputToTheRight(); const searchValue = tokensContainer.querySelector('.filtered-search-term .name'); expect(searchValue.innerText).toEqual('test'); @@ -592,10 +633,10 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = ` ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} ${FilteredSearchSpecHelper.createInputHTML()} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} `; - gl.FilteredSearchVisualTokens.moveInputToTheRight(); + subject.moveInputToTheRight(); expect(tokensContainer.children[2].querySelector('.filtered-search')).not.toEqual(null); }); @@ -607,7 +648,7 @@ describe('Filtered Search Visual Tokens', () => { ${FilteredSearchSpecHelper.createInputHTML('', '~bug')} `; - gl.FilteredSearchVisualTokens.moveInputToTheRight(); + subject.moveInputToTheRight(); const token = tokensContainer.children[1]; expect(token.querySelector('.value').innerText).toEqual('~bug'); @@ -615,42 +656,144 @@ describe('Filtered Search Visual Tokens', () => { }); describe('renderVisualTokenValue', () => { - let searchTokens; + const keywordToken = FilteredSearchSpecHelper.createFilterVisualToken('search'); + const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken('milestone', 'upcoming'); + + let updateLabelTokenColorSpy; + let updateUserTokenAppearanceSpy; beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} - ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} + ${authorToken.outerHTML} + ${bugLabelToken.outerHTML} + ${keywordToken.outerHTML} + ${milestoneToken.outerHTML} `); - searchTokens = document.querySelectorAll('.filtered-search-token'); + spyOn(subject, 'updateLabelTokenColor'); + updateLabelTokenColorSpy = subject.updateLabelTokenColor; + + spyOn(subject, 'updateUserTokenAppearance'); + updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance; }); - it('renders a token value element', () => { - spyOn(gl.FilteredSearchVisualTokens, 'updateLabelTokenColor'); - const updateLabelTokenColorSpy = gl.FilteredSearchVisualTokens.updateLabelTokenColor; + it('renders a author token value element', () => { + const { tokenNameElement, tokenValueContainer, tokenValueElement } = + findElements(authorToken); + const tokenName = tokenNameElement.innerText; + const tokenValue = 'new value'; - expect(searchTokens.length).toBe(2); - Array.prototype.forEach.call(searchTokens, (token) => { - updateLabelTokenColorSpy.calls.reset(); + subject.renderVisualTokenValue(authorToken, tokenName, tokenValue); - const tokenName = token.querySelector('.name').innerText; - const tokenValue = 'new value'; - gl.FilteredSearchVisualTokens.renderVisualTokenValue(token, tokenName, tokenValue); + expect(tokenValueElement.innerText).toBe(tokenValue); + expect(updateUserTokenAppearanceSpy.calls.count()).toBe(1); + const expectedArgs = [tokenValueContainer, tokenValueElement, tokenValue]; + expect(updateUserTokenAppearanceSpy.calls.argsFor(0)).toEqual(expectedArgs); + expect(updateLabelTokenColorSpy.calls.count()).toBe(0); + }); - const tokenValueElement = token.querySelector('.value'); - expect(tokenValueElement.innerText).toBe(tokenValue); + it('renders a label token value element', () => { + const { tokenNameElement, tokenValueContainer, tokenValueElement } = + findElements(bugLabelToken); + const tokenName = tokenNameElement.innerText; + const tokenValue = 'new value'; - if (tokenName.toLowerCase() === 'label') { - const tokenValueContainer = token.querySelector('.value-container'); - expect(updateLabelTokenColorSpy.calls.count()).toBe(1); - const expectedArgs = [tokenValueContainer, tokenValue]; - expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs); - } else { - expect(updateLabelTokenColorSpy.calls.count()).toBe(0); - } - }); + subject.renderVisualTokenValue(bugLabelToken, tokenName, tokenValue); + + expect(tokenValueElement.innerText).toBe(tokenValue); + expect(updateLabelTokenColorSpy.calls.count()).toBe(1); + const expectedArgs = [tokenValueContainer, tokenValue]; + expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs); + expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0); + }); + + it('renders a milestone token value element', () => { + const { tokenNameElement, tokenValueElement } = findElements(milestoneToken); + const tokenName = tokenNameElement.innerText; + const tokenValue = 'new value'; + + subject.renderVisualTokenValue(milestoneToken, tokenName, tokenValue); + + expect(tokenValueElement.innerText).toBe(tokenValue); + expect(updateLabelTokenColorSpy.calls.count()).toBe(0); + expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0); + }); + }); + + describe('updateUserTokenAppearance', () => { + let usersCacheSpy; + + beforeEach(() => { + spyOn(UsersCache, 'retrieve').and.callFake(username => usersCacheSpy(username)); + }); + + it('ignores special value "none"', (done) => { + usersCacheSpy = (username) => { + expect(username).toBe('none'); + done.fail('Should not resolve "none"!'); + }; + const { tokenValueContainer, tokenValueElement } = findElements(authorToken); + + subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, 'none') + .then(done) + .catch(done.fail); + }); + + it('ignores error if UsersCache throws', (done) => { + spyOn(window, 'Flash'); + const dummyError = new Error('Earth rotated backwards'); + const { tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = (username) => { + expect(`@${username}`).toBe(tokenValue); + return Promise.reject(dummyError); + }; + + subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(window.Flash.calls.count()).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('does nothing if user cannot be found', (done) => { + const { tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = (username) => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(undefined); + }; + + subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueElement.innerText).toBe(tokenValue); + }) + .then(done) + .catch(done.fail); + }); + + it('replaces author token with avatar and display name', (done) => { + const dummyUser = { + name: 'Important Person', + avatar_url: 'https://host.invalid/mypics/avatar.png', + }; + const { tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = (username) => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(dummyUser); + }; + + subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + const avatar = tokenValueElement.querySelector('img.avatar'); + expect(avatar.src).toBe(dummyUser.avatar_url); + }) + .then(done) + .catch(done.fail); }); }); @@ -659,21 +802,16 @@ describe('Filtered Search Visual Tokens', () => { const dummyEndpoint = '/dummy/endpoint'; preloadFixtures(jsonFixtureName); - const labelData = getJSONFixture(jsonFixtureName); - const findLabel = tokenValue => labelData.find( - label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`, - ); - const bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); + let labelData; + + beforeAll(() => { + labelData = getJSONFixture(jsonFixtureName); + }); + const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~doesnotexist'); const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~"some space"'); - const parseColor = (color) => { - const dummyElement = document.createElement('div'); - dummyElement.style.color = color; - return dummyElement.style.color; - }; - beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${bugLabelToken.outerHTML} @@ -688,28 +826,60 @@ describe('Filtered Search Visual Tokens', () => { AjaxCache.internalStorage[`${dummyEndpoint}/labels.json`] = labelData; }); - const testCase = (token, done) => { - const tokenValueContainer = token.querySelector('.value-container'); - const tokenValue = token.querySelector('.value').innerText; - const label = findLabel(tokenValue); + const parseColor = (color) => { + const dummyElement = document.createElement('div'); + dummyElement.style.color = color; + return dummyElement.style.color; + }; - gl.FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue) - .then(() => { - if (label) { - expect(tokenValueContainer.getAttribute('style')).not.toBe(null); - expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color)); - expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color)); - } else { - expect(token).toBe(missingLabelToken); - expect(tokenValueContainer.getAttribute('style')).toBe(null); - } - }) - .then(done) - .catch(fail); + const expectValueContainerStyle = (tokenValueContainer, label) => { + expect(tokenValueContainer.getAttribute('style')).not.toBe(null); + expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color)); + expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color)); }; - it('updates the color of a label token', done => testCase(bugLabelToken, done)); - it('updates the color of a label token with spaces', done => testCase(spaceLabelToken, done)); - it('does not change color of a missing label', done => testCase(missingLabelToken, done)); + const findLabel = tokenValue => labelData.find( + label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`, + ); + + it('updates the color of a label token', (done) => { + const { tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + + subject.updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expectValueContainerStyle(tokenValueContainer, matchingLabel); + }) + .then(done) + .catch(done.fail); + }); + + it('updates the color of a label token with spaces', (done) => { + const { tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + + subject.updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expectValueContainerStyle(tokenValueContainer, matchingLabel); + }) + .then(done) + .catch(done.fail); + }); + + it('does not change color of a missing label', (done) => { + const { tokenValueContainer, tokenValueElement } = findElements(missingLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + expect(matchingLabel).toBe(undefined); + + subject.updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expect(tokenValueContainer.getAttribute('style')).toBe(null); + }) + .then(done) + .catch(done.fail); + }); }); }); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js index 31fa478804a..c293c0afa97 100644 --- a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js +++ b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js @@ -1,6 +1,5 @@ -/* eslint-disable promise/catch-or-return */ - import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; import AccessorUtilities from '~/lib/utils/accessor'; describe('RecentSearchesService', () => { @@ -22,11 +21,9 @@ describe('RecentSearchesService', () => { fetchItemsPromise .then((items) => { expect(items).toEqual([]); - done(); }) - .catch((err) => { - done.fail('Shouldn\'t reject with empty localStorage key', err); - }); + .then(done) + .catch(done.fail); }); it('should reject when unable to parse', (done) => { @@ -34,19 +31,24 @@ describe('RecentSearchesService', () => { const fetchItemsPromise = service.fetch(); fetchItemsPromise + .then(done.fail) .catch((error) => { expect(error).toEqual(jasmine.any(SyntaxError)); - done(); - }); + }) + .then(done) + .catch(done.fail); }); it('should reject when service is unavailable', (done) => { RecentSearchesService.isAvailable.and.returnValue(false); - service.fetch().catch((error) => { - expect(error).toEqual(jasmine.any(Error)); - done(); - }); + service.fetch() + .then(done.fail) + .catch((error) => { + expect(error).toEqual(jasmine.any(Error)); + }) + .then(done) + .catch(done.fail); }); it('should return items from localStorage', (done) => { @@ -56,8 +58,9 @@ describe('RecentSearchesService', () => { fetchItemsPromise .then((items) => { expect(items).toEqual(['foo', 'bar']); - done(); - }); + }) + .then(done) + .catch(done.fail); }); describe('if .isAvailable returns `false`', () => { @@ -65,12 +68,17 @@ describe('RecentSearchesService', () => { RecentSearchesService.isAvailable.and.returnValue(false); spyOn(window.localStorage, 'getItem'); - - RecentSearchesService.prototype.fetch(); }); - it('should not call .getItem', () => { - expect(window.localStorage.getItem).not.toHaveBeenCalled(); + it('should not call .getItem', (done) => { + RecentSearchesService.prototype.fetch() + .then(done.fail) + .catch((err) => { + expect(err).toEqual(new RecentSearchesServiceError()); + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); }); }); @@ -105,11 +113,11 @@ describe('RecentSearchesService', () => { RecentSearchesService.isAvailable.and.returnValue(true); spyOn(JSON, 'stringify').and.returnValue(searchesString); - - RecentSearchesService.prototype.save.call(recentSearchesService); }); it('should call .setItem', () => { + RecentSearchesService.prototype.save.call(recentSearchesService); + expect(window.localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString); }); }); @@ -117,11 +125,11 @@ describe('RecentSearchesService', () => { describe('if .isAvailable returns `false`', () => { beforeEach(() => { RecentSearchesService.isAvailable.and.returnValue(false); - - RecentSearchesService.prototype.save(); }); it('should not call .setItem', () => { + RecentSearchesService.prototype.save(); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); }); }); diff --git a/spec/javascripts/fixtures/balsamiq.rb b/spec/javascripts/fixtures/balsamiq.rb new file mode 100644 index 00000000000..b5372821bf5 --- /dev/null +++ b/spec/javascripts/fixtures/balsamiq.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe 'Balsamiq file', '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, namespace: namespace, path: 'balsamiq-project') } + + before(:all) do + clean_frontend_fixtures('blob/balsamiq/') + end + + it 'blob/balsamiq/test.bmpr' do |example| + blob = project.repository.blob_at('b89b56d79', 'files/images/balsamiq.bmpr') + + store_frontend_fixture(blob.data.force_encoding('utf-8'), example.description) + end +end diff --git a/spec/javascripts/fixtures/balsamiq_viewer.html.haml b/spec/javascripts/fixtures/balsamiq_viewer.html.haml new file mode 100644 index 00000000000..18166ba4901 --- /dev/null +++ b/spec/javascripts/fixtures/balsamiq_viewer.html.haml @@ -0,0 +1 @@ +.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: '/test' } } diff --git a/spec/javascripts/fixtures/boards.rb b/spec/javascripts/fixtures/boards.rb new file mode 100644 index 00000000000..d7c3dc0a235 --- /dev/null +++ b/spec/javascripts/fixtures/boards.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe Projects::BoardsController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, :repository, namespace: namespace, path: 'boards-project') } + + render_views + + before(:all) do + clean_frontend_fixtures('boards/') + end + + before(:each) do + sign_in(admin) + end + + it 'boards/show.html.raw' do |example| + get(:index, + namespace_id: project.namespace, + project_id: project) + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/fixtures/issuable_filter.html.haml b/spec/javascripts/fixtures/issuable_filter.html.haml index ae745b292e6..84fa5395cb8 100644 --- a/spec/javascripts/fixtures/issuable_filter.html.haml +++ b/spec/javascripts/fixtures/issuable_filter.html.haml @@ -1,6 +1,6 @@ %form.js-filter-form{action: '/user/project/issues?scope=all&state=closed'} %input{id: 'utf8', name: 'utf8', value: '✓'} - %input{id: 'check_all_issues', name: 'check_all_issues'} + %input{id: 'check-all-issues', name: 'check-all-issues'} %input{id: 'search', name: 'search'} %input{id: 'author_id', name: 'author_id'} %input{id: 'assignee_id', name: 'assignee_id'} diff --git a/spec/javascripts/fixtures/issues.rb b/spec/javascripts/fixtures/issues.rb index 88e3f860809..1a30909977e 100644 --- a/spec/javascripts/fixtures/issues.rb +++ b/spec/javascripts/fixtures/issues.rb @@ -36,6 +36,17 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller render_issue(example.description, issue) end + it 'issues/issue_list.html.raw' do |example| + create(:issue, project: project) + + get :index, + namespace_id: project.namespace.to_param, + project_id: project + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + private def render_issue(fixture_file_name, issue) diff --git a/spec/javascripts/fixtures/builds.rb b/spec/javascripts/fixtures/jobs.rb index 320de791b08..dc7dde1138c 100644 --- a/spec/javascripts/fixtures/builds.rb +++ b/spec/javascripts/fixtures/jobs.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Projects::BuildsController, '(JavaScript fixtures)', type: :controller do +describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers let(:admin) { create(:admin) } diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb index 47d904b865b..a746a776548 100644 --- a/spec/javascripts/fixtures/merge_requests.rb +++ b/spec/javascripts/fixtures/merge_requests.rb @@ -16,6 +16,16 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont sha: merge_request.diff_head_sha ) end + let(:path) { "files/ruby/popen.rb" } + let(:position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + old_line: nil, + new_line: 14, + diff_refs: merge_request.diff_refs + ) + end render_views @@ -39,6 +49,12 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont render_merge_request(example.description, merged_merge_request) end + it 'merge_requests/diff_comment.html.raw' do |example| + create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) + create(:note_on_merge_request, author: admin, project: project, noteable: merge_request) + render_merge_request(example.description, merge_request) + end + private def render_merge_request(fixture_file_name, merge_request) diff --git a/spec/javascripts/fixtures/pipelines.rb b/spec/javascripts/fixtures/pipelines.rb new file mode 100644 index 00000000000..daafbac86db --- /dev/null +++ b/spec/javascripts/fixtures/pipelines.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') } + let(:commit) { create(:commit, project: project) } + let(:commit_without_author) { RepoHelpers.another_sample_commit } + let!(:user) { create(:user, email: commit.author_email) } + let!(:pipeline) { create(:ci_pipeline, project: project, sha: commit.id, user: user) } + let!(:pipeline_without_author) { create(:ci_pipeline, project: project, sha: commit_without_author.id) } + let!(:pipeline_without_commit) { create(:ci_pipeline, project: project, sha: '0000') } + + render_views + + before(:all) do + clean_frontend_fixtures('pipelines/') + end + + before(:each) do + sign_in(admin) + end + + it 'pipelines/pipelines.json' do |example| + get :index, + namespace_id: namespace, + project_id: project, + format: :json + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/fixtures/raw.rb b/spec/javascripts/fixtures/raw.rb index 1ce622fc836..17533443d76 100644 --- a/spec/javascripts/fixtures/raw.rb +++ b/spec/javascripts/fixtures/raw.rb @@ -21,4 +21,10 @@ describe 'Raw files', '(JavaScript fixtures)', type: :controller do store_frontend_fixture(blob.data, example.description) end + + it 'blob/notebook/math.json' do |example| + blob = project.repository.blob_at('93ee732', 'files/ipython/math.ipynb') + + store_frontend_fixture(blob.data, example.description) + end end diff --git a/spec/javascripts/fixtures/services.rb b/spec/javascripts/fixtures/services.rb new file mode 100644 index 00000000000..554451d1bbf --- /dev/null +++ b/spec/javascripts/fixtures/services.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') } + let!(:service) { create(:custom_issue_tracker_service, project: project, title: 'Custom Issue Tracker') } + + + render_views + + before(:all) do + clean_frontend_fixtures('services/') + end + + before(:each) do + sign_in(admin) + end + + it 'services/edit_service.html.raw' do |example| + get :edit, + namespace_id: namespace, + project_id: project, + id: service.to_param + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/gfm_auto_complete_spec.js b/spec/javascripts/gfm_auto_complete_spec.js index 5dfa4008fbd..ad0c7264616 100644 --- a/spec/javascripts/gfm_auto_complete_spec.js +++ b/spec/javascripts/gfm_auto_complete_spec.js @@ -1,13 +1,15 @@ /* eslint no-param-reassign: "off" */ -require('~/gfm_auto_complete'); -require('vendor/jquery.caret'); -require('vendor/jquery.atwho'); +import GfmAutoComplete from '~/gfm_auto_complete'; -const global = window.gl || (window.gl = {}); -const GfmAutoComplete = global.GfmAutoComplete; +import 'vendor/jquery.caret'; +import 'vendor/jquery.atwho'; describe('GfmAutoComplete', function () { + const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ + fetchData: () => {}, + }); + describe('DefaultOptions.sorter', function () { describe('assets loading', function () { beforeEach(function () { @@ -16,7 +18,7 @@ describe('GfmAutoComplete', function () { this.atwhoInstance = { setting: {} }; this.items = []; - this.sorterValue = GfmAutoComplete.DefaultOptions.sorter + this.sorterValue = gfmAutoCompleteCallbacks.sorter .call(this.atwhoInstance, '', this.items); }); @@ -38,7 +40,7 @@ describe('GfmAutoComplete', function () { it('should enable highlightFirst if alwaysHighlightFirst is set', function () { const atwhoInstance = { setting: { alwaysHighlightFirst: true } }; - GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance); + gfmAutoCompleteCallbacks.sorter.call(atwhoInstance); expect(atwhoInstance.setting.highlightFirst).toBe(true); }); @@ -46,7 +48,7 @@ describe('GfmAutoComplete', function () { it('should enable highlightFirst if a query is present', function () { const atwhoInstance = { setting: {} }; - GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance, 'query'); + gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, 'query'); expect(atwhoInstance.setting.highlightFirst).toBe(true); }); @@ -58,7 +60,7 @@ describe('GfmAutoComplete', function () { const items = []; const searchKey = 'searchKey'; - GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance, query, items, searchKey); + gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, query, items, searchKey); expect($.fn.atwho.default.callbacks.sorter).toHaveBeenCalledWith(query, items, searchKey); }); @@ -67,7 +69,7 @@ describe('GfmAutoComplete', function () { describe('DefaultOptions.matcher', function () { const defaultMatcher = (context, flag, subtext) => ( - GfmAutoComplete.DefaultOptions.matcher.call(context, flag, subtext) + gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext) ); const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%']; diff --git a/spec/javascripts/gl_dropdown_spec.js b/spec/javascripts/gl_dropdown_spec.js index eb532dff5a1..3292590b9ed 100644 --- a/spec/javascripts/gl_dropdown_spec.js +++ b/spec/javascripts/gl_dropdown_spec.js @@ -1,9 +1,8 @@ /* eslint-disable comma-dangle, no-param-reassign, no-unused-expressions, max-len */ -require('~/gl_dropdown'); -require('~/lib/utils/common_utils'); -require('~/lib/utils/type_utility'); -require('~/lib/utils/url_utility'); +import '~/gl_dropdown'; +import '~/lib/utils/common_utils'; +import '~/lib/utils/url_utility'; (() => { const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link'; diff --git a/spec/javascripts/gl_emoji_spec.js b/spec/javascripts/gl_emoji_spec.js index b2b46640e5b..a09e0072fa8 100644 --- a/spec/javascripts/gl_emoji_spec.js +++ b/spec/javascripts/gl_emoji_spec.js @@ -192,6 +192,9 @@ describe('gl_emoji', () => { }); describe('isFlagEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isFlagEmoji('')).toBeFalsy(); + }); it('should detect flag_ac', () => { expect(isFlagEmoji('🇦🇨')).toBeTruthy(); }); @@ -216,6 +219,9 @@ describe('gl_emoji', () => { }); describe('isKeycapEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isKeycapEmoji('')).toBeFalsy(); + }); it('should detect one(keycap)', () => { expect(isKeycapEmoji('1️⃣')).toBeTruthy(); }); @@ -231,6 +237,9 @@ describe('gl_emoji', () => { }); describe('isSkinToneComboEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isSkinToneComboEmoji('')).toBeFalsy(); + }); it('should detect hand_splayed_tone5', () => { expect(isSkinToneComboEmoji('🖐🏿')).toBeTruthy(); }); @@ -255,6 +264,9 @@ describe('gl_emoji', () => { }); describe('isHorceRacingSkinToneComboEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isHorceRacingSkinToneComboEmoji('')).toBeFalsy(); + }); it('should detect horse_racing_tone2', () => { expect(isHorceRacingSkinToneComboEmoji('🏇🏼')).toBeTruthy(); }); @@ -264,6 +276,9 @@ describe('gl_emoji', () => { }); describe('isPersonZwjEmoji', () => { + it('should gracefully handle empty string', () => { + expect(isPersonZwjEmoji('')).toBeFalsy(); + }); it('should detect couple_mm', () => { expect(isPersonZwjEmoji('👨❤️👨')).toBeTruthy(); }); @@ -300,6 +315,22 @@ describe('gl_emoji', () => { }); describe('isEmojiUnicodeSupported', () => { + it('should gracefully handle empty string with unicode support', () => { + const isSupported = isEmojiUnicodeSupported( + { '1.0': true }, + '', + '1.0', + ); + expect(isSupported).toBeTruthy(); + }); + it('should gracefully handle empty string without unicode support', () => { + const isSupported = isEmojiUnicodeSupported( + {}, + '', + '1.0', + ); + expect(isSupported).toBeFalsy(); + }); it('bomb(6.0) with 6.0 support', () => { const emojiKey = 'bomb'; const unicodeSupportMap = Object.assign({}, emptySupportMap, { diff --git a/spec/javascripts/gl_field_errors_spec.js b/spec/javascripts/gl_field_errors_spec.js index 733023481f5..fa24aa426b6 100644 --- a/spec/javascripts/gl_field_errors_spec.js +++ b/spec/javascripts/gl_field_errors_spec.js @@ -1,6 +1,6 @@ /* eslint-disable space-before-function-paren, arrow-body-style */ -require('~/gl_field_errors'); +import '~/gl_field_errors'; ((global) => { preloadFixtures('static/gl_field_errors.html.raw'); diff --git a/spec/javascripts/gl_form_spec.js b/spec/javascripts/gl_form_spec.js index 71d6e2a7e22..837feacec1d 100644 --- a/spec/javascripts/gl_form_spec.js +++ b/spec/javascripts/gl_form_spec.js @@ -1,9 +1,9 @@ -/* global autosize */ +import autosize from 'vendor/autosize'; +import '~/gl_form'; +import '~/lib/utils/text_utility'; +import '~/lib/utils/common_utils'; -window.autosize = require('vendor/autosize'); -require('~/gl_form'); -require('~/lib/utils/text_utility'); -require('~/lib/utils/common_utils'); +window.autosize = autosize; describe('GLForm', () => { const global = window.gl || (window.gl = {}); @@ -27,12 +27,12 @@ describe('GLForm', () => { $.prototype.off.calls.reset(); $.prototype.on.calls.reset(); $.prototype.css.calls.reset(); - autosize.calls.reset(); + window.autosize.calls.reset(); done(); }); }); - describe('.setupAutosize', () => { + describe('setupAutosize', () => { beforeEach((done) => { this.glForm.setupAutosize(); setTimeout(() => { @@ -51,7 +51,7 @@ describe('GLForm', () => { }); it('should autosize the textarea', () => { - expect(autosize).toHaveBeenCalledWith(jasmine.any(Object)); + expect(window.autosize).toHaveBeenCalledWith(jasmine.any(Object)); }); it('should set the resize css property to vertical', () => { @@ -59,7 +59,7 @@ describe('GLForm', () => { }); }); - describe('.setHeightData', () => { + describe('setHeightData', () => { beforeEach(() => { spyOn($.prototype, 'data'); spyOn($.prototype, 'outerHeight').and.returnValue(200); @@ -75,13 +75,13 @@ describe('GLForm', () => { }); }); - describe('.destroyAutosize', () => { + describe('destroyAutosize', () => { describe('when called', () => { beforeEach(() => { spyOn($.prototype, 'data'); spyOn($.prototype, 'outerHeight').and.returnValue(200); spyOn(window, 'outerHeight').and.returnValue(400); - spyOn(autosize, 'destroy'); + spyOn(window.autosize, 'destroy'); this.glForm.destroyAutosize(); }); @@ -95,7 +95,7 @@ describe('GLForm', () => { }); it('should call autosize destroy', () => { - expect(autosize.destroy).toHaveBeenCalledWith(this.textarea); + expect(window.autosize.destroy).toHaveBeenCalledWith(this.textarea); }); it('should set the data-height attribute', () => { @@ -114,9 +114,9 @@ describe('GLForm', () => { it('should return undefined if the data-height equals the outerHeight', () => { spyOn($.prototype, 'outerHeight').and.returnValue(200); spyOn($.prototype, 'data').and.returnValue(200); - spyOn(autosize, 'destroy'); + spyOn(window.autosize, 'destroy'); expect(this.glForm.destroyAutosize()).toBeUndefined(); - expect(autosize.destroy).not.toHaveBeenCalled(); + expect(window.autosize.destroy).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/javascripts/header_spec.js b/spec/javascripts/header_spec.js index b5dde5525e5..0e01934d3a3 100644 --- a/spec/javascripts/header_spec.js +++ b/spec/javascripts/header_spec.js @@ -1,7 +1,7 @@ /* eslint-disable space-before-function-paren, no-var */ -require('~/header'); -require('~/lib/utils/text_utility'); +import '~/header'; +import '~/lib/utils/text_utility'; (function() { describe('Header', function() { diff --git a/spec/javascripts/helpers/class_spec_helper.js b/spec/javascripts/helpers/class_spec_helper.js index 61db27a8fcc..7a60d33b471 100644 --- a/spec/javascripts/helpers/class_spec_helper.js +++ b/spec/javascripts/helpers/class_spec_helper.js @@ -1,4 +1,4 @@ -class ClassSpecHelper { +export default class ClassSpecHelper { static itShouldBeAStaticMethod(base, method) { return it('should be a static method', () => { expect(Object.prototype.hasOwnProperty.call(base, method)).toBeTruthy(); @@ -7,5 +7,3 @@ class ClassSpecHelper { } window.ClassSpecHelper = ClassSpecHelper; - -module.exports = ClassSpecHelper; diff --git a/spec/javascripts/helpers/class_spec_helper_spec.js b/spec/javascripts/helpers/class_spec_helper_spec.js index 0a61e561640..686b8eaed31 100644 --- a/spec/javascripts/helpers/class_spec_helper_spec.js +++ b/spec/javascripts/helpers/class_spec_helper_spec.js @@ -1,9 +1,9 @@ /* global ClassSpecHelper */ -require('./class_spec_helper'); +import './class_spec_helper'; describe('ClassSpecHelper', () => { - describe('.itShouldBeAStaticMethod', function () { + describe('itShouldBeAStaticMethod', function () { beforeEach(() => { class TestClass { instanceMethod() { this.prop = 'val'; } diff --git a/spec/javascripts/helpers/filtered_search_spec_helper.js b/spec/javascripts/helpers/filtered_search_spec_helper.js index b8d4a93b1ab..8933dd5def4 100644 --- a/spec/javascripts/helpers/filtered_search_spec_helper.js +++ b/spec/javascripts/helpers/filtered_search_spec_helper.js @@ -1,4 +1,4 @@ -class FilteredSearchSpecHelper { +export default class FilteredSearchSpecHelper { static createFilterVisualTokenHTML(name, value, isSelected) { return FilteredSearchSpecHelper.createFilterVisualToken(name, value, isSelected).outerHTML; } @@ -30,12 +30,15 @@ class FilteredSearchSpecHelper { `; } + static createSearchVisualToken(name) { + const li = document.createElement('li'); + li.classList.add('js-visual-token', 'filtered-search-term'); + li.innerHTML = `<div class="name">${name}</div>`; + return li; + } + static createSearchVisualTokenHTML(name) { - return ` - <li class="js-visual-token filtered-search-term"> - <div class="name">${name}</div> - </li> - `; + return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML; } static createInputHTML(placeholder = '', value = '') { @@ -53,5 +56,3 @@ class FilteredSearchSpecHelper { `; } } - -module.exports = FilteredSearchSpecHelper; diff --git a/spec/javascripts/integrations/integration_settings_form_spec.js b/spec/javascripts/integrations/integration_settings_form_spec.js new file mode 100644 index 00000000000..45909d4e70e --- /dev/null +++ b/spec/javascripts/integrations/integration_settings_form_spec.js @@ -0,0 +1,199 @@ +import IntegrationSettingsForm from '~/integrations/integration_settings_form'; + +describe('IntegrationSettingsForm', () => { + const FIXTURE = 'services/edit_service.html.raw'; + preloadFixtures(FIXTURE); + + beforeEach(() => { + loadFixtures(FIXTURE); + }); + + describe('contructor', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + spyOn(integrationSettingsForm, 'init'); + }); + + it('should initialize form element refs on class object', () => { + // Form Reference + expect(integrationSettingsForm.$form).toBeDefined(); + expect(integrationSettingsForm.$form.prop('nodeName')).toEqual('FORM'); + + // Form Child Elements + expect(integrationSettingsForm.$serviceToggle).toBeDefined(); + expect(integrationSettingsForm.$submitBtn).toBeDefined(); + expect(integrationSettingsForm.$submitBtnLoader).toBeDefined(); + expect(integrationSettingsForm.$submitBtnLabel).toBeDefined(); + }); + + it('should initialize form metadata on class object', () => { + expect(integrationSettingsForm.testEndPoint).toBeDefined(); + expect(integrationSettingsForm.canTestService).toBeDefined(); + }); + }); + + describe('toggleServiceState', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + }); + + it('should remove `novalidate` attribute to form when called with `true`', () => { + integrationSettingsForm.toggleServiceState(true); + + expect(integrationSettingsForm.$form.attr('novalidate')).not.toBeDefined(); + }); + + it('should set `novalidate` attribute to form when called with `false`', () => { + integrationSettingsForm.toggleServiceState(false); + + expect(integrationSettingsForm.$form.attr('novalidate')).toBeDefined(); + }); + }); + + describe('toggleSubmitBtnLabel', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + }); + + it('should set Save button label to "Test settings and save changes" when serviceActive & canTestService are `true`', () => { + integrationSettingsForm.canTestService = true; + + integrationSettingsForm.toggleSubmitBtnLabel(true); + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Test settings and save changes'); + }); + + it('should set Save button label to "Save changes" when either serviceActive or canTestService (or both) is `false`', () => { + integrationSettingsForm.canTestService = false; + + integrationSettingsForm.toggleSubmitBtnLabel(false); + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); + + integrationSettingsForm.toggleSubmitBtnLabel(true); + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); + + integrationSettingsForm.canTestService = true; + + integrationSettingsForm.toggleSubmitBtnLabel(false); + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); + }); + }); + + describe('toggleSubmitBtnState', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + }); + + it('should disable Save button and show loader animation when called with `true`', () => { + integrationSettingsForm.toggleSubmitBtnState(true); + + expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeTruthy(); + expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeFalsy(); + }); + + it('should enable Save button and hide loader animation when called with `false`', () => { + integrationSettingsForm.toggleSubmitBtnState(false); + + expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeFalsy(); + expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeTruthy(); + }); + }); + + describe('testSettings', () => { + let integrationSettingsForm; + let formData; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + formData = integrationSettingsForm.$form.serialize(); + }); + + it('should make an ajax request with provided `formData`', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + expect($.ajax).toHaveBeenCalledWith({ + type: 'PUT', + url: integrationSettingsForm.testEndPoint, + data: formData, + }); + }); + + it('should show error Flash with `Save anyway` action if ajax request responds with error in test', () => { + const errorMessage = 'Test failed.'; + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + deferred.resolve({ error: true, message: errorMessage }); + + const $flashContainer = $('.flash-container'); + expect($flashContainer.find('.flash-text').text()).toEqual(errorMessage); + expect($flashContainer.find('.flash-action')).toBeDefined(); + expect($flashContainer.find('.flash-action').text()).toEqual('Save anyway'); + }); + + it('should submit form if ajax request responds without any error in test', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + spyOn(integrationSettingsForm.$form, 'submit'); + deferred.resolve({ error: false }); + + expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); + }); + + it('should submit form when clicked on `Save anyway` action of error Flash', () => { + const errorMessage = 'Test failed.'; + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + deferred.resolve({ error: true, message: errorMessage }); + + const $flashAction = $('.flash-container .flash-action'); + expect($flashAction).toBeDefined(); + + spyOn(integrationSettingsForm.$form, 'submit'); + $flashAction.trigger('click'); + expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); + }); + + it('should show error Flash if ajax request failed', () => { + const errorMessage = 'Something went wrong on our end.'; + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + deferred.reject(); + + expect($('.flash-container .flash-text').text()).toEqual(errorMessage); + }); + + it('should always call `toggleSubmitBtnState` with `false` once request is completed', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + spyOn(integrationSettingsForm, 'toggleSubmitBtnState'); + deferred.reject(); + + expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/spec/javascripts/issuable_spec.js b/spec/javascripts/issuable_spec.js index 26d87cc5931..45f55395d3a 100644 --- a/spec/javascripts/issuable_spec.js +++ b/spec/javascripts/issuable_spec.js @@ -1,7 +1,7 @@ -/* global Issuable */ +/* global IssuableIndex */ -require('~/lib/utils/url_utility'); -require('~/issuable'); +import '~/lib/utils/url_utility'; +import '~/issuable_index'; (() => { const BASE_URL = '/user/project/issues?scope=all&state=closed'; @@ -24,11 +24,11 @@ require('~/issuable'); beforeEach(() => { loadFixtures('static/issuable_filter.html.raw'); - Issuable.init(); + IssuableIndex.init(); }); it('should be defined', () => { - expect(window.Issuable).toBeDefined(); + expect(window.IssuableIndex).toBeDefined(); }); describe('filtering', () => { @@ -43,7 +43,7 @@ require('~/issuable'); it('should contain only the default parameters', () => { spyOn(gl.utils, 'visitUrl'); - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + DEFAULT_PARAMS); }); @@ -52,7 +52,7 @@ require('~/issuable'); spyOn(gl.utils, 'visitUrl'); updateForm({ search: 'broken' }, $filtersForm); - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); const params = `${DEFAULT_PARAMS}&search=broken`; expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params); @@ -64,14 +64,14 @@ require('~/issuable'); // initial filter updateForm({ milestone_title: 'v1.0' }, $filtersForm); - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); let params = `${DEFAULT_PARAMS}&milestone_title=v1.0`; expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params); // update filter updateForm({ label_name: 'Frontend' }, $filtersForm); - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); params = `${DEFAULT_PARAMS}&milestone_title=v1.0&label_name=Frontend`; expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params); }); diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js new file mode 100644 index 00000000000..59c006aa0af --- /dev/null +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -0,0 +1,377 @@ +import Vue from 'vue'; +import '~/render_math'; +import '~/render_gfm'; +import issuableApp from '~/issue_show/components/app.vue'; +import eventHub from '~/issue_show/event_hub'; +import issueShowData from '../mock_data'; + +const issueShowInterceptor = data => (request, next) => { + next(request.respondWith(JSON.stringify(data), { + status: 200, + headers: { + 'POLL-INTERVAL': 1, + }, + })); +}; + +function formatText(text) { + return text.trim().replace(/\s\s+/g, ' '); +} + +describe('Issuable output', () => { + document.body.innerHTML = '<span id="task_status"></span>'; + + let vm; + + beforeEach(() => { + const IssuableDescriptionComponent = Vue.extend(issuableApp); + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + + spyOn(eventHub, '$emit'); + + vm = new IssuableDescriptionComponent({ + propsData: { + canUpdate: true, + canDestroy: true, + canMove: true, + endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes', + issuableRef: '#1', + initialTitleHtml: '', + initialTitleText: '', + initialDescriptionHtml: '', + initialDescriptionText: '', + markdownPreviewUrl: '/', + markdownDocs: '/', + projectsAutocompleteUrl: '/', + isConfidential: false, + projectNamespace: '/', + projectPath: '/', + }, + }).$mount(); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); + }); + + it('should render a title/description/edited and update title/description/edited on update', (done) => { + setTimeout(() => { + const editedText = vm.$el.querySelector('.edited-text'); + + expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); + expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); + expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); + expect(vm.$el.querySelector('.js-task-list-field').value).toContain('this is a description'); + expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/); + expect(editedText.querySelector('.author_link').href).toMatch(/\/some_user$/); + expect(editedText.querySelector('time')).toBeTruthy(); + + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); + + setTimeout(() => { + expect(document.querySelector('title').innerText).toContain('2 (#1)'); + expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); + expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); + expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42'); + expect(vm.$el.querySelector('.edited-text')).toBeTruthy(); + expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/); + expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/); + expect(editedText.querySelector('time')).toBeTruthy(); + + done(); + }); + }); + }); + + it('shows actions if permissions are correct', (done) => { + vm.showForm = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn'), + ).not.toBeNull(); + + done(); + }); + }); + + it('does not show actions if permissions are incorrect', (done) => { + vm.showForm = true; + vm.canUpdate = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn'), + ).toBeNull(); + + done(); + }); + }); + + it('does not update formState if form is already open', (done) => { + vm.openForm(); + + vm.state.titleText = 'testing 123'; + + vm.openForm(); + + Vue.nextTick(() => { + expect( + vm.store.formState.title, + ).not.toBe('testing 123'); + + done(); + }); + }); + + describe('updateIssuable', () => { + it('fetches new data after update', (done) => { + spyOn(vm.service, 'getData'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + confidential: false, + web_url: location.pathname, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + vm.service.getData, + ).toHaveBeenCalled(); + + done(); + }); + }); + + it('reloads the page if the confidential status has changed', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + confidential: true, + web_url: location.pathname, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith(location.pathname); + + done(); + }); + }); + + it('correctly updates issuable data', (done) => { + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve(); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + vm.service.updateIssuable, + ).toHaveBeenCalledWith(vm.formState); + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + + done(); + }); + }); + + it('does not redirect if issue has not moved', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + web_url: location.pathname, + confidential: vm.isConfidential, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).not.toHaveBeenCalled(); + + done(); + }); + }); + + it('redirects if issue is moved', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + web_url: '/testing-issue-move', + confidential: vm.isConfidential, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith('/testing-issue-move'); + + done(); + }); + }); + + it('does not update issuable if project move confirm is false', (done) => { + spyOn(window, 'confirm').and.returnValue(false); + spyOn(vm.service, 'updateIssuable'); + + vm.store.formState.move_to_project_id = 1; + + vm.updateIssuable(); + + setTimeout(() => { + expect( + vm.service.updateIssuable, + ).not.toHaveBeenCalled(); + + done(); + }); + }); + + it('closes form on error', (done) => { + spyOn(window, 'Flash').and.callThrough(); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => { + reject(); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + expect( + window.Flash, + ).toHaveBeenCalledWith('Error updating issue'); + + done(); + }); + }); + }); + + describe('deleteIssuable', () => { + it('changes URL when deleted', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { web_url: '/test' }; + }, + }); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith('/test'); + + done(); + }); + }); + + it('stops polling when deleting', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.poll, 'stop'); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { web_url: '/test' }; + }, + }); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + vm.poll.stop, + ).toHaveBeenCalledWith(); + + done(); + }); + }); + + it('closes form on error', (done) => { + spyOn(window, 'Flash').and.callThrough(); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve, reject) => { + reject(); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + expect( + window.Flash, + ).toHaveBeenCalledWith('Error deleting issue'); + + done(); + }); + }); + }); + + describe('open form', () => { + it('shows locked warning if form is open & data is different', (done) => { + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + + Vue.nextTick() + .then(() => new Promise((resolve) => { + setTimeout(resolve); + })) + .then(() => { + vm.openForm(); + + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); + + return new Promise((resolve) => { + setTimeout(resolve); + }); + }) + .then(() => { + expect( + vm.formState.lockedWarningVisible, + ).toBeTruthy(); + + expect( + vm.$el.querySelector('.alert'), + ).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js new file mode 100644 index 00000000000..408349cc42d --- /dev/null +++ b/spec/javascripts/issue_show/components/description_spec.js @@ -0,0 +1,99 @@ +import Vue from 'vue'; +import descriptionComponent from '~/issue_show/components/description.vue'; + +describe('Description component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(descriptionComponent); + + if (!document.querySelector('.issuable-meta')) { + const metaData = document.createElement('div'); + metaData.classList.add('issuable-meta'); + metaData.innerHTML = '<span id="task_status"></span><span id="task_status_short"></span>'; + + document.body.appendChild(metaData); + } + + vm = new Component({ + propsData: { + canUpdate: true, + descriptionHtml: 'test', + descriptionText: 'test', + updatedAt: new Date().toString(), + taskStatus: '', + }, + }).$mount(); + }); + + it('animates description changes', (done) => { + vm.descriptionHtml = 'changed'; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.wiki').classList.contains('issue-realtime-pre-pulse'), + ).toBeTruthy(); + + setTimeout(() => { + expect( + vm.$el.querySelector('.wiki').classList.contains('issue-realtime-trigger-pulse'), + ).toBeTruthy(); + + done(); + }); + }); + }); + + it('re-inits the TaskList when description changed', (done) => { + spyOn(gl, 'TaskList'); + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect( + gl.TaskList, + ).toHaveBeenCalled(); + + done(); + }); + }); + + it('does not re-init the TaskList when canUpdate is false', (done) => { + spyOn(gl, 'TaskList'); + vm.canUpdate = false; + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect( + gl.TaskList, + ).not.toHaveBeenCalled(); + + done(); + }); + }); + + describe('taskStatus', () => { + it('adds full taskStatus', (done) => { + vm.taskStatus = '1 of 1'; + + setTimeout(() => { + expect( + document.querySelector('.issuable-meta #task_status').textContent.trim(), + ).toBe('1 of 1'); + + done(); + }); + }); + + it('adds short taskStatus', (done) => { + vm.taskStatus = '1 of 1'; + + setTimeout(() => { + expect( + document.querySelector('.issuable-meta #task_status_short').textContent.trim(), + ).toBe('1/1 task'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/edit_actions_spec.js b/spec/javascripts/issue_show/components/edit_actions_spec.js new file mode 100644 index 00000000000..f6625b748b6 --- /dev/null +++ b/spec/javascripts/issue_show/components/edit_actions_spec.js @@ -0,0 +1,147 @@ +import Vue from 'vue'; +import editActions from '~/issue_show/components/edit_actions.vue'; +import eventHub from '~/issue_show/event_hub'; +import Store from '~/issue_show/stores'; + +describe('Edit Actions components', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(editActions); + const store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.title = 'test'; + + spyOn(eventHub, '$emit'); + + vm = new Component({ + propsData: { + canDestroy: true, + formState: store.formState, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders all buttons as enabled', () => { + expect( + vm.$el.querySelectorAll('.disabled').length, + ).toBe(0); + + expect( + vm.$el.querySelectorAll('[disabled]').length, + ).toBe(0); + }); + + it('does not render delete button if canUpdate is false', (done) => { + vm.canDestroy = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-danger'), + ).toBeNull(); + + done(); + }); + }); + + it('disables submit button when title is blank', (done) => { + vm.formState.title = ''; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save').getAttribute('disabled'), + ).toBe('disabled'); + + done(); + }); + }); + + describe('updateIssuable', () => { + it('sends update.issauble event when clicking save button', () => { + vm.$el.querySelector('.btn-save').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('update.issuable'); + }); + + it('shows loading icon after clicking save button', (done) => { + vm.$el.querySelector('.btn-save').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save .fa'), + ).not.toBeNull(); + + done(); + }); + }); + + it('disabled button after clicking save button', (done) => { + vm.$el.querySelector('.btn-save').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save').getAttribute('disabled'), + ).toBe('disabled'); + + done(); + }); + }); + }); + + describe('closeForm', () => { + it('emits close.form when clicking cancel', () => { + vm.$el.querySelector('.btn-default').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + }); + }); + + describe('deleteIssuable', () => { + it('sends delete.issuable event when clicking save button', () => { + spyOn(window, 'confirm').and.returnValue(true); + vm.$el.querySelector('.btn-danger').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('delete.issuable'); + }); + + it('shows loading icon after clicking delete button', (done) => { + spyOn(window, 'confirm').and.returnValue(true); + vm.$el.querySelector('.btn-danger').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-danger .fa'), + ).not.toBeNull(); + + done(); + }); + }); + + it('does no actions when confirm is false', (done) => { + spyOn(window, 'confirm').and.returnValue(false); + vm.$el.querySelector('.btn-danger').click(); + + Vue.nextTick(() => { + expect( + eventHub.$emit, + ).not.toHaveBeenCalledWith('delete.issuable'); + expect( + vm.$el.querySelector('.btn-danger .fa'), + ).toBeNull(); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/description_spec.js b/spec/javascripts/issue_show/components/fields/description_spec.js new file mode 100644 index 00000000000..f5b35b1e8b0 --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/description_spec.js @@ -0,0 +1,56 @@ +import Vue from 'vue'; +import Store from '~/issue_show/stores'; +import descriptionField from '~/issue_show/components/fields/description.vue'; + +describe('Description field component', () => { + let vm; + let store; + + beforeEach((done) => { + const Component = Vue.extend(descriptionField); + const el = document.createElement('div'); + store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.description = 'test'; + + document.body.appendChild(el); + + vm = new Component({ + el, + propsData: { + markdownPreviewUrl: '/', + markdownDocs: '/', + formState: store.formState, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders markdown field with description', () => { + expect( + vm.$el.querySelector('.md-area textarea').value, + ).toBe('test'); + }); + + it('renders markdown field with a markdown description', (done) => { + store.formState.description = '**test**'; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.md-area textarea').value, + ).toBe('**test**'); + + done(); + }); + }); + + it('focuses field when mounted', () => { + expect( + document.activeElement, + ).toBe(vm.$refs.textarea); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/description_template_spec.js b/spec/javascripts/issue_show/components/fields/description_template_spec.js new file mode 100644 index 00000000000..2b7ee65094b --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/description_template_spec.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import descriptionTemplate from '~/issue_show/components/fields/description_template.vue'; +import '~/templates/issuable_template_selector'; +import '~/templates/issuable_template_selectors'; + +describe('Issue description template component', () => { + let vm; + let formState; + + beforeEach((done) => { + const Component = Vue.extend(descriptionTemplate); + formState = { + description: 'test', + }; + + vm = new Component({ + propsData: { + formState, + issuableTemplates: [{ name: 'test' }], + projectPath: '/', + projectNamespace: '/', + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders templates as JSON array in data attribute', () => { + expect( + vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data'), + ).toBe('[{"name":"test"}]'); + }); + + it('updates formState when changing template', () => { + vm.issuableTemplate.editor.setValue('test new template'); + + expect( + formState.description, + ).toBe('test new template'); + }); + + it('returns formState description with editor getValue', () => { + formState.description = 'testing new template'; + + expect( + vm.issuableTemplate.editor.getValue(), + ).toBe('testing new template'); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/project_move_spec.js b/spec/javascripts/issue_show/components/fields/project_move_spec.js new file mode 100644 index 00000000000..86d35c33ff4 --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/project_move_spec.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import projectMove from '~/issue_show/components/fields/project_move.vue'; + +describe('Project move field component', () => { + let vm; + let formState; + + beforeEach((done) => { + const Component = Vue.extend(projectMove); + + formState = { + move_to_project_id: 0, + }; + + vm = new Component({ + propsData: { + formState, + projectsAutocompleteUrl: '/autocomplete', + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('mounts select2 element', () => { + expect( + vm.$el.querySelector('.select2-container'), + ).not.toBeNull(); + }); + + it('updates formState on change', () => { + $(vm.$refs['move-dropdown']).val(2).trigger('change'); + + expect( + formState.move_to_project_id, + ).toBe(2); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/title_spec.js b/spec/javascripts/issue_show/components/fields/title_spec.js new file mode 100644 index 00000000000..53ae038a6a2 --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/title_spec.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import Store from '~/issue_show/stores'; +import titleField from '~/issue_show/components/fields/title.vue'; + +describe('Title field component', () => { + let vm; + let store; + + beforeEach(() => { + const Component = Vue.extend(titleField); + store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.title = 'test'; + + vm = new Component({ + propsData: { + formState: store.formState, + }, + }).$mount(); + }); + + it('renders form control with formState title', () => { + expect( + vm.$el.querySelector('.form-control').value, + ).toBe('test'); + }); +}); diff --git a/spec/javascripts/issue_show/components/form_spec.js b/spec/javascripts/issue_show/components/form_spec.js new file mode 100644 index 00000000000..9a85223208c --- /dev/null +++ b/spec/javascripts/issue_show/components/form_spec.js @@ -0,0 +1,68 @@ +import Vue from 'vue'; +import formComponent from '~/issue_show/components/form.vue'; +import '~/templates/issuable_template_selector'; +import '~/templates/issuable_template_selectors'; + +describe('Inline edit form component', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(formComponent); + + vm = new Component({ + propsData: { + canDestroy: true, + canMove: true, + formState: { + title: 'b', + description: 'a', + lockedWarningVisible: false, + }, + markdownPreviewUrl: '/', + markdownDocs: '/', + projectsAutocompleteUrl: '/', + projectPath: '/', + projectNamespace: '/', + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('does not render template selector if no templates exist', () => { + expect( + vm.$el.querySelector('.js-issuable-selector-wrap'), + ).toBeNull(); + }); + + it('renders template selector when templates exists', (done) => { + spyOn(gl, 'IssuableTemplateSelectors'); + vm.issuableTemplates = ['test']; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.js-issuable-selector-wrap'), + ).not.toBeNull(); + + done(); + }); + }); + + it('hides locked warning by default', () => { + expect( + vm.$el.querySelector('.alert'), + ).toBeNull(); + }); + + it('shows locked warning if formState is different', (done) => { + vm.formState.lockedWarningVisible = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.alert'), + ).not.toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js new file mode 100644 index 00000000000..a2d90a9b9f5 --- /dev/null +++ b/spec/javascripts/issue_show/components/title_spec.js @@ -0,0 +1,75 @@ +import Vue from 'vue'; +import Store from '~/issue_show/stores'; +import titleComponent from '~/issue_show/components/title.vue'; + +describe('Title component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(titleComponent); + const store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + vm = new Component({ + propsData: { + issuableRef: '#1', + titleHtml: 'Testing <img />', + titleText: 'Testing', + showForm: false, + formState: store.formState, + }, + }).$mount(); + }); + + it('renders title HTML', () => { + expect( + vm.$el.innerHTML.trim(), + ).toBe('Testing <img>'); + }); + + it('updates page title when changing titleHtml', (done) => { + spyOn(vm, 'setPageTitle'); + vm.titleHtml = 'test'; + + Vue.nextTick(() => { + expect( + vm.setPageTitle, + ).toHaveBeenCalled(); + + done(); + }); + }); + + it('animates title changes', (done) => { + vm.titleHtml = 'test'; + + Vue.nextTick(() => { + expect( + vm.$el.classList.contains('issue-realtime-pre-pulse'), + ).toBeTruthy(); + + setTimeout(() => { + expect( + vm.$el.classList.contains('issue-realtime-trigger-pulse'), + ).toBeTruthy(); + + done(); + }); + }); + }); + + it('updates page title after changing title', (done) => { + vm.titleHtml = 'changed'; + vm.titleText = 'changed'; + + Vue.nextTick(() => { + expect( + document.querySelector('title').textContent.trim(), + ).toContain('changed'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js index a4562449ff1..eb3111412a7 100644 --- a/spec/javascripts/issue_show/mock_data.js +++ b/spec/javascripts/issue_show/mock_data.js @@ -4,7 +4,6 @@ export default { title_text: 'this is a title', description: '<p>this is a description!</p>', description_text: 'this is a description', - issue_number: 1, task_status: '2 of 4 completed', updated_at: '2015-05-15T12:31:04.428Z', updated_by_name: 'Some User', @@ -15,7 +14,6 @@ export default { title_text: '2', description: '<p>42</p>', description_text: '42', - issue_number: 1, task_status: '0 of 0 completed', updated_at: '2016-05-15T12:31:04.428Z', updated_by_name: 'Other User', @@ -26,7 +24,6 @@ export default { title_text: 'this is a title', description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>', description_text: '- [ ] Task List Item', - issue_number: 1, task_status: '0 of 1 completed', updated_at: '2017-05-15T12:31:04.428Z', updated_by_name: 'Last User', diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index 763f5ee9e50..df97a100b0d 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -1,7 +1,7 @@ /* eslint-disable space-before-function-paren, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */ import Issue from '~/issue'; -require('~/lib/utils/text_utility'); +import '~/lib/utils/text_utility'; describe('Issue', function() { let $boxClosed, $boxOpen, $btnClose, $btnReopen; diff --git a/spec/javascripts/labels_issue_sidebar_spec.js b/spec/javascripts/labels_issue_sidebar_spec.js index 37e038c16da..c99f379b871 100644 --- a/spec/javascripts/labels_issue_sidebar_spec.js +++ b/spec/javascripts/labels_issue_sidebar_spec.js @@ -2,15 +2,14 @@ /* global IssuableContext */ /* global LabelsSelect */ -require('~/lib/utils/type_utility'); -require('~/gl_dropdown'); -require('select2'); -require('vendor/jquery.nicescroll'); -require('~/api'); -require('~/create_label'); -require('~/issuable_context'); -require('~/users_select'); -require('~/labels_select'); +import '~/gl_dropdown'; +import 'select2'; +import 'vendor/jquery.nicescroll'; +import '~/api'; +import '~/create_label'; +import '~/issuable_context'; +import '~/users_select'; +import '~/labels_select'; (() => { let saveLabelCount = 0; diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js index 7b466a11b92..2c946802dcd 100644 --- a/spec/javascripts/lib/utils/ajax_cache_spec.js +++ b/spec/javascripts/lib/utils/ajax_cache_spec.js @@ -5,19 +5,13 @@ describe('AjaxCache', () => { const dummyResponse = { important: 'dummy data', }; - let ajaxSpy = (url) => { - expect(url).toBe(dummyEndpoint); - const deferred = $.Deferred(); - deferred.resolve(dummyResponse); - return deferred.promise(); - }; beforeEach(() => { AjaxCache.internalStorage = { }; - spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url)); + AjaxCache.pendingRequests = { }; }); - describe('#get', () => { + describe('get', () => { it('returns undefined if cache is empty', () => { const data = AjaxCache.get(dummyEndpoint); @@ -41,7 +35,7 @@ describe('AjaxCache', () => { }); }); - describe('#hasData', () => { + describe('hasData', () => { it('returns false if cache is empty', () => { expect(AjaxCache.hasData(dummyEndpoint)).toBe(false); }); @@ -59,9 +53,9 @@ describe('AjaxCache', () => { }); }); - describe('#purge', () => { + describe('remove', () => { it('does nothing if cache is empty', () => { - AjaxCache.purge(dummyEndpoint); + AjaxCache.remove(dummyEndpoint); expect(AjaxCache.internalStorage).toEqual({ }); }); @@ -69,7 +63,7 @@ describe('AjaxCache', () => { it('does nothing if cache contains no matching data', () => { AjaxCache.internalStorage['not matching'] = dummyResponse; - AjaxCache.purge(dummyEndpoint); + AjaxCache.remove(dummyEndpoint); expect(AjaxCache.internalStorage['not matching']).toBe(dummyResponse); }); @@ -77,14 +71,27 @@ describe('AjaxCache', () => { it('removes matching data', () => { AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; - AjaxCache.purge(dummyEndpoint); + AjaxCache.remove(dummyEndpoint); expect(AjaxCache.internalStorage).toEqual({ }); }); }); - describe('#retrieve', () => { + describe('retrieve', () => { + let ajaxSpy; + + beforeEach(() => { + spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url)); + }); + it('stores and returns data from Ajax call if cache is empty', (done) => { + ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.resolve(dummyResponse); + return deferred.promise(); + }; + AjaxCache.retrieve(dummyEndpoint) .then((data) => { expect(data).toBe(dummyResponse); @@ -94,6 +101,28 @@ describe('AjaxCache', () => { .catch(fail); }); + it('makes no Ajax call if request is pending', () => { + const responseDeferred = $.Deferred(); + + ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + // neither reject nor resolve to keep request pending + return responseDeferred.promise(); + }; + + const unexpectedResponse = data => fail(`Did not expect response: ${data}`); + + AjaxCache.retrieve(dummyEndpoint) + .then(unexpectedResponse) + .catch(fail); + + AjaxCache.retrieve(dummyEndpoint) + .then(unexpectedResponse) + .catch(fail); + + expect($.ajax.calls.count()).toBe(1); + }); + it('returns undefined if Ajax call fails and cache is empty', (done) => { const dummyStatusText = 'exploded'; const dummyErrorMessage = 'server exploded'; @@ -125,5 +154,36 @@ describe('AjaxCache', () => { .then(done) .catch(fail); }); + + it('makes Ajax call even if matching data exists when forceRequest parameter is provided', (done) => { + const oldDummyResponse = { + important: 'old dummy data', + }; + + AjaxCache.internalStorage[dummyEndpoint] = oldDummyResponse; + + ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.resolve(dummyResponse); + return deferred.promise(); + }; + + // Call without forceRetrieve param + AjaxCache.retrieve(dummyEndpoint) + .then((data) => { + expect(data).toBe(oldDummyResponse); + }) + .then(done) + .catch(fail); + + // Call with forceRetrieve param + AjaxCache.retrieve(dummyEndpoint, true) + .then((data) => { + expect(data).toBe(dummyResponse); + }) + .then(done) + .catch(fail); + }); }); }); diff --git a/spec/javascripts/lib/utils/cache_spec.js b/spec/javascripts/lib/utils/cache_spec.js new file mode 100644 index 00000000000..2fe02a7592c --- /dev/null +++ b/spec/javascripts/lib/utils/cache_spec.js @@ -0,0 +1,65 @@ +import Cache from '~/lib/utils/cache'; + +describe('Cache', () => { + const dummyKey = 'just some key'; + const dummyValue = 'more than a value'; + let cache; + + beforeEach(() => { + cache = new Cache(); + }); + + describe('get', () => { + it('return cached data', () => { + cache.internalStorage[dummyKey] = dummyValue; + + expect(cache.get(dummyKey)).toBe(dummyValue); + }); + + it('returns undefined for missing data', () => { + expect(cache.internalStorage[dummyKey]).toBe(undefined); + expect(cache.get(dummyKey)).toBe(undefined); + }); + }); + + describe('hasData', () => { + it('return true for cached data', () => { + cache.internalStorage[dummyKey] = dummyValue; + + expect(cache.hasData(dummyKey)).toBe(true); + }); + + it('returns false for missing data', () => { + expect(cache.internalStorage[dummyKey]).toBe(undefined); + expect(cache.hasData(dummyKey)).toBe(false); + }); + }); + + describe('remove', () => { + it('removes data from cache', () => { + cache.internalStorage[dummyKey] = dummyValue; + + cache.remove(dummyKey); + + expect(cache.internalStorage[dummyKey]).toBe(undefined); + }); + + it('does nothing for missing data', () => { + expect(cache.internalStorage[dummyKey]).toBe(undefined); + + cache.remove(dummyKey); + + expect(cache.internalStorage[dummyKey]).toBe(undefined); + }); + + it('does not remove wrong data', () => { + cache.internalStorage[dummyKey] = dummyValue; + cache.internalStorage[dummyKey + dummyKey] = dummyValue + dummyValue; + + cache.remove(dummyKey); + + expect(cache.internalStorage[dummyKey]).toBe(undefined); + expect(cache.internalStorage[dummyKey + dummyKey]).toBe(dummyValue + dummyValue); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index 5eb147ed888..e3938a77680 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -1,6 +1,6 @@ /* eslint-disable promise/catch-or-return */ -require('~/lib/utils/common_utils'); +import '~/lib/utils/common_utils'; (() => { describe('common_utils', () => { @@ -41,6 +41,16 @@ require('~/lib/utils/common_utils'); const paramsArray = gl.utils.getUrlParamsArray(); expect(paramsArray[0][0] !== '?').toBe(true); }); + + it('should decode params', () => { + history.pushState('', '', '?label_name%5B%5D=test'); + + expect( + gl.utils.getUrlParamsArray()[0], + ).toBe('label_name[]=test'); + + history.pushState('', '', '?'); + }); }); describe('gl.utils.handleLocationHash', () => { @@ -346,7 +356,7 @@ require('~/lib/utils/common_utils'); describe('gl.utils.setCiStatusFavicon', () => { it('should set page favicon to CI status favicon based on provided status', () => { - const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1/status.json`; + const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1/status.json`; const FAVICON_PATH = '//icon_status_success'; const spySetFavicon = spyOn(gl.utils, 'setFavicon').and.stub(); const spyResetFavicon = spyOn(gl.utils, 'resetFavicon').and.stub(); diff --git a/spec/javascripts/lib/utils/number_utility_spec.js b/spec/javascripts/lib/utils/number_utility_spec.js index 90b12c9f115..83c92deccdc 100644 --- a/spec/javascripts/lib/utils/number_utility_spec.js +++ b/spec/javascripts/lib/utils/number_utility_spec.js @@ -1,4 +1,4 @@ -import { formatRelevantDigits, bytesToKiB } from '~/lib/utils/number_utils'; +import { formatRelevantDigits, bytesToKiB, bytesToMiB } from '~/lib/utils/number_utils'; describe('Number Utils', () => { describe('formatRelevantDigits', () => { @@ -45,4 +45,11 @@ describe('Number Utils', () => { expect(bytesToKiB(1000)).toEqual(0.9765625); }); }); + + describe('bytesToMiB', () => { + it('calculates MiB for the given bytes', () => { + expect(bytesToMiB(1048576)).toEqual(1); + expect(bytesToMiB(1000000)).toEqual(0.95367431640625); + }); + }); }); diff --git a/spec/javascripts/lib/utils/poll_spec.js b/spec/javascripts/lib/utils/poll_spec.js index 918b6d32c43..22f30191ab9 100644 --- a/spec/javascripts/lib/utils/poll_spec.js +++ b/spec/javascripts/lib/utils/poll_spec.js @@ -1,9 +1,5 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; import Poll from '~/lib/utils/poll'; -Vue.use(VueResource); - const waitForAllCallsToFinish = (service, waitForCount, successCallback) => { const timer = () => { setTimeout(() => { @@ -12,45 +8,33 @@ const waitForAllCallsToFinish = (service, waitForCount, successCallback) => { } else { timer(); } - }, 5); + }, 0); }; timer(); }; -class ServiceMock { - constructor(endpoint) { - this.service = Vue.resource(endpoint); - } +function mockServiceCall(service, response, shouldFail = false) { + const action = shouldFail ? Promise.reject : Promise.resolve; + const responseObject = response; + + if (!responseObject.headers) responseObject.headers = {}; - fetch() { - return this.service.get(); - } + service.fetch.and.callFake(action.bind(Promise, responseObject)); } describe('Poll', () => { - let callbacks; - let service; + const service = jasmine.createSpyObj('service', ['fetch']); + const callbacks = jasmine.createSpyObj('callbacks', ['success', 'error']); - beforeEach(() => { - callbacks = { - success: () => {}, - error: () => {}, - }; - - service = new ServiceMock('endpoint'); - - spyOn(callbacks, 'success'); - spyOn(callbacks, 'error'); - spyOn(service, 'fetch').and.callThrough(); + afterEach(() => { + callbacks.success.calls.reset(); + callbacks.error.calls.reset(); + service.fetch.calls.reset(); }); it('calls the success callback when no header for interval is provided', (done) => { - const successInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200 })); - }; - - Vue.http.interceptors.push(successInterceptor); + mockServiceCall(service, { status: 200 }); new Poll({ resource: service, @@ -63,18 +47,12 @@ describe('Poll', () => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, successInterceptor); - done(); - }, 0); + }); }); it('calls the error callback whe the http request returns an error', (done) => { - const errorInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 500 })); - }; - - Vue.http.interceptors.push(errorInterceptor); + mockServiceCall(service, { status: 500 }, true); new Poll({ resource: service, @@ -86,42 +64,29 @@ describe('Poll', () => { waitForAllCallsToFinish(service, 1, () => { expect(callbacks.success).not.toHaveBeenCalled(); expect(callbacks.error).toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor); done(); }); }); it('should call the success callback when the interval header is -1', (done) => { - const intervalInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': -1 } })); - }; - - Vue.http.interceptors.push(intervalInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': -1 } }); new Poll({ resource: service, method: 'fetch', successCallback: callbacks.success, errorCallback: callbacks.error, - }).makeRequest(); - - setTimeout(() => { + }).makeRequest().then(() => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, intervalInterceptor); - done(); - }, 0); + }).catch(done.fail); }); it('starts polling when http status is 200 and interval header is provided', (done) => { - const pollInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } })); - }; - - Vue.http.interceptors.push(pollInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ resource: service, @@ -141,19 +106,13 @@ describe('Poll', () => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); - done(); }); }); describe('stop', () => { it('stops polling when method is called', (done) => { - const pollInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } })); - }; - - Vue.http.interceptors.push(pollInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ resource: service, @@ -174,8 +133,6 @@ describe('Poll', () => { expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(Polling.stop).toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); - done(); }); }); @@ -183,11 +140,7 @@ describe('Poll', () => { describe('restart', () => { it('should restart polling when its called', (done) => { - const pollInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify([]), { status: 200, headers: { 'poll-interval': 2 } })); - }; - - Vue.http.interceptors.push(pollInterceptor); + mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ resource: service, @@ -215,8 +168,6 @@ describe('Poll', () => { expect(Polling.stop).toHaveBeenCalled(); expect(Polling.restart).toHaveBeenCalled(); - Vue.http.interceptors = _.without(Vue.http.interceptors, pollInterceptor); - done(); }); }); diff --git a/spec/javascripts/lib/utils/text_utility_spec.js b/spec/javascripts/lib/utils/text_utility_spec.js index daef9b93fa5..ca1b1b7cc3c 100644 --- a/spec/javascripts/lib/utils/text_utility_spec.js +++ b/spec/javascripts/lib/utils/text_utility_spec.js @@ -1,4 +1,4 @@ -require('~/lib/utils/text_utility'); +import '~/lib/utils/text_utility'; describe('text_utility', () => { describe('gl.text.getTextWidth', () => { diff --git a/spec/javascripts/lib/utils/users_cache_spec.js b/spec/javascripts/lib/utils/users_cache_spec.js new file mode 100644 index 00000000000..ec6ea35952b --- /dev/null +++ b/spec/javascripts/lib/utils/users_cache_spec.js @@ -0,0 +1,136 @@ +import Api from '~/api'; +import UsersCache from '~/lib/utils/users_cache'; + +describe('UsersCache', () => { + const dummyUsername = 'win'; + const dummyUser = 'has a farm'; + + beforeEach(() => { + UsersCache.internalStorage = { }; + }); + + describe('get', () => { + it('returns undefined for empty cache', () => { + expect(UsersCache.internalStorage).toEqual({ }); + + const user = UsersCache.get(dummyUsername); + + expect(user).toBe(undefined); + }); + + it('returns undefined for missing user', () => { + UsersCache.internalStorage['no body'] = 'no data'; + + const user = UsersCache.get(dummyUsername); + + expect(user).toBe(undefined); + }); + + it('returns matching user', () => { + UsersCache.internalStorage[dummyUsername] = dummyUser; + + const user = UsersCache.get(dummyUsername); + + expect(user).toBe(dummyUser); + }); + }); + + describe('hasData', () => { + it('returns false for empty cache', () => { + expect(UsersCache.internalStorage).toEqual({ }); + + expect(UsersCache.hasData(dummyUsername)).toBe(false); + }); + + it('returns false for missing user', () => { + UsersCache.internalStorage['no body'] = 'no data'; + + expect(UsersCache.hasData(dummyUsername)).toBe(false); + }); + + it('returns true for matching user', () => { + UsersCache.internalStorage[dummyUsername] = dummyUser; + + expect(UsersCache.hasData(dummyUsername)).toBe(true); + }); + }); + + describe('remove', () => { + it('does nothing if cache is empty', () => { + expect(UsersCache.internalStorage).toEqual({ }); + + UsersCache.remove(dummyUsername); + + expect(UsersCache.internalStorage).toEqual({ }); + }); + + it('does nothing if cache contains no matching data', () => { + UsersCache.internalStorage['no body'] = 'no data'; + + UsersCache.remove(dummyUsername); + + expect(UsersCache.internalStorage['no body']).toBe('no data'); + }); + + it('removes matching data', () => { + UsersCache.internalStorage[dummyUsername] = dummyUser; + + UsersCache.remove(dummyUsername); + + expect(UsersCache.internalStorage).toEqual({ }); + }); + }); + + describe('retrieve', () => { + let apiSpy; + + beforeEach(() => { + spyOn(Api, 'users').and.callFake((query, options) => apiSpy(query, options)); + }); + + it('stores and returns data from API call if cache is empty', (done) => { + apiSpy = (query, options) => { + expect(query).toBe(''); + expect(options).toEqual({ username: dummyUsername }); + return Promise.resolve([dummyUser]); + }; + + UsersCache.retrieve(dummyUsername) + .then((user) => { + expect(user).toBe(dummyUser); + expect(UsersCache.internalStorage[dummyUsername]).toBe(dummyUser); + }) + .then(done) + .catch(done.fail); + }); + + it('returns undefined if Ajax call fails and cache is empty', (done) => { + const dummyError = new Error('server exploded'); + apiSpy = (query, options) => { + expect(query).toBe(''); + expect(options).toEqual({ username: dummyUsername }); + return Promise.reject(dummyError); + }; + + UsersCache.retrieve(dummyUsername) + .then(user => fail(`Received unexpected user: ${JSON.stringify(user)}`)) + .catch((error) => { + expect(error).toBe(dummyError); + }) + .then(done) + .catch(done.fail); + }); + + it('makes no Ajax call if matching data exists', (done) => { + UsersCache.internalStorage[dummyUsername] = dummyUser; + apiSpy = () => fail(new Error('expected no Ajax call!')); + + UsersCache.retrieve(dummyUsername) + .then((user) => { + expect(user).toBe(dummyUser); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/line_highlighter_spec.js b/spec/javascripts/line_highlighter_spec.js index a1fd2d38968..aee274641e8 100644 --- a/spec/javascripts/line_highlighter_spec.js +++ b/spec/javascripts/line_highlighter_spec.js @@ -1,7 +1,7 @@ /* eslint-disable space-before-function-paren, no-var, no-param-reassign, quotes, prefer-template, no-else-return, new-cap, dot-notation, no-return-assign, comma-dangle, no-new, one-var, one-var-declaration-per-line, jasmine/no-spec-dupes, no-underscore-dangle, max-len */ /* global LineHighlighter */ -require('~/line_highlighter'); +import '~/line_highlighter'; (function() { describe('LineHighlighter', function() { @@ -58,7 +58,7 @@ require('~/line_highlighter'); return expect(func).not.toThrow(); }); }); - describe('#clickHandler', function() { + describe('clickHandler', function() { it('handles clicking on a child icon element', function() { var spy; spy = spyOn(this["class"], 'setHash').and.callThrough(); @@ -176,7 +176,7 @@ require('~/line_highlighter'); }); }); }); - describe('#hashToRange', function() { + describe('hashToRange', function() { beforeEach(function() { return this.subject = this["class"].hashToRange; }); @@ -190,7 +190,7 @@ require('~/line_highlighter'); return expect(this.subject('#foo')).toEqual([null, null]); }); }); - describe('#highlightLine', function() { + describe('highlightLine', function() { beforeEach(function() { return this.subject = this["class"].highlightLine; }); @@ -203,7 +203,7 @@ require('~/line_highlighter'); return expect($('#LC13')).toHaveClass(this.css); }); }); - return describe('#setHash', function() { + return describe('setHash', function() { beforeEach(function() { return this.subject = this["class"].setHash; }); diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js new file mode 100644 index 00000000000..e54acfa8e44 --- /dev/null +++ b/spec/javascripts/merge_request_notes_spec.js @@ -0,0 +1,61 @@ +/* global Notes */ + +import 'vendor/autosize'; +import '~/gl_form'; +import '~/lib/utils/text_utility'; +import '~/render_gfm'; +import '~/render_math'; +import '~/notes'; + +describe('Merge request notes', () => { + window.gon = window.gon || {}; + window.gl = window.gl || {}; + gl.utils = gl.utils || {}; + + const fixture = 'merge_requests/diff_comment.html.raw'; + preloadFixtures(fixture); + + beforeEach(() => { + loadFixtures(fixture); + gl.utils.disableButtonIfEmptyField = _.noop; + window.project_uploads_path = 'http://test.host/uploads'; + $('body').data('page', 'projects:merge_requests:show'); + window.gon.current_user_id = $('.note:last').data('author-id'); + + return new Notes('', []); + }); + + describe('up arrow', () => { + it('edits last comment when triggered in main form', () => { + const upArrowEvent = $.Event('keydown'); + upArrowEvent.which = 38; + + spyOnEvent('.note:last .js-note-edit', 'click'); + + $('.js-note-text').trigger(upArrowEvent); + + expect('click').toHaveBeenTriggeredOn('.note:last .js-note-edit'); + }); + + it('edits last comment in discussion when triggered in discussion form', (done) => { + const upArrowEvent = $.Event('keydown'); + upArrowEvent.which = 38; + + spyOnEvent('.note-discussion .js-note-edit', 'click'); + + $('.js-discussion-reply-button').click(); + + setTimeout(() => { + expect( + $('.note-discussion .js-note-text'), + ).toExist(); + + $('.note-discussion .js-note-text').trigger(upArrowEvent); + + expect('click').toHaveBeenTriggeredOn('.note-discussion .js-note-edit'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js index fd97dced870..f444bcaf847 100644 --- a/spec/javascripts/merge_request_spec.js +++ b/spec/javascripts/merge_request_spec.js @@ -1,7 +1,7 @@ /* eslint-disable space-before-function-paren, no-return-assign */ /* global MergeRequest */ -require('~/merge_request'); +import '~/merge_request'; (function() { describe('MergeRequest', function() { @@ -13,7 +13,9 @@ require('~/merge_request'); }); it('modifies the Markdown field', function() { spyOn(jQuery, 'ajax').and.stub(); - $('input[type=checkbox]').attr('checked', true).trigger('change'); + const changeEvent = document.createEvent('HTMLEvents'); + changeEvent.initEvent('change', true, true); + $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent); return expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); }); return it('submits an ajax request on tasklist:changed', function() { diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index e437333d522..7b910282cc8 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -1,13 +1,15 @@ /* eslint-disable no-var, comma-dangle, object-shorthand */ - -require('~/merge_request_tabs'); -require('~/commit/pipelines/pipelines_bundle.js'); -require('~/breakpoints'); -require('~/lib/utils/common_utils'); -require('~/diff'); -require('~/single_file_diff'); -require('~/files_comment_button'); -require('vendor/jquery.scrollTo'); +/* global Notes */ + +import '~/merge_request_tabs'; +import '~/commit/pipelines/pipelines_bundle'; +import '~/breakpoints'; +import '~/lib/utils/common_utils'; +import '~/diff'; +import '~/single_file_diff'; +import '~/files_comment_button'; +import '~/notes'; +import 'vendor/jquery.scrollTo'; (function () { // TODO: remove this hack! @@ -29,7 +31,7 @@ require('vendor/jquery.scrollTo'); }; $.extend(stubLocation, defaults, stubs || {}); }; - preloadFixtures('merge_requests/merge_request_with_task_list.html.raw'); + preloadFixtures('merge_requests/merge_request_with_task_list.html.raw', 'merge_requests/diff_comment.html.raw'); beforeEach(function () { this.class = new gl.MergeRequestTabs({ stubLocation: stubLocation }); @@ -47,7 +49,7 @@ require('vendor/jquery.scrollTo'); this.class.destroyPipelinesView(); }); - describe('#activateTab', function () { + describe('activateTab', function () { beforeEach(function () { spyOn($, 'ajax').and.callFake(function () {}); loadFixtures('merge_requests/merge_request_with_task_list.html.raw'); @@ -71,7 +73,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#opensInNewTab', function () { + describe('opensInNewTab', function () { var tabUrl; var windowTarget = '_blank'; @@ -152,7 +154,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#setCurrentAction', function () { + describe('setCurrentAction', function () { beforeEach(function () { spyOn($, 'ajax').and.callFake(function () {}); this.subject = this.class.setCurrentAction; @@ -221,7 +223,7 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#tabShown', () => { + describe('tabShown', () => { beforeEach(function () { spyOn($, 'ajax').and.callFake(function (options) { options.success({ html: '' }); @@ -281,13 +283,54 @@ require('vendor/jquery.scrollTo'); }); }); - describe('#loadDiff', function () { + describe('loadDiff', function () { it('requires an absolute pathname', function () { spyOn($, 'ajax').and.callFake(function (options) { expect(options.url).toEqual('/foo/bar/merge_requests/1/diffs.json'); }); + this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); }); + + describe('with note fragment hash', () => { + beforeEach(() => { + loadFixtures('merge_requests/diff_comment.html.raw'); + spyOn(window.gl.utils, 'getPagePath').and.returnValue('merge_requests'); + window.notes = new Notes('', []); + spyOn(window.notes, 'toggleDiffNote').and.callThrough(); + }); + + afterEach(() => { + delete window.notes; + }); + + it('should expand and scroll to linked fragment hash #note_xxx', function () { + const noteId = 'note_1'; + spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteId); + spyOn($, 'ajax').and.callFake(function (options) { + options.success({ html: `<div id="${noteId}">foo</div>` }); + }); + + this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); + + expect(window.notes.toggleDiffNote).toHaveBeenCalledWith({ + target: jasmine.any(Object), + lineType: 'old', + forceShow: true, + }); + }); + + it('should gracefully ignore non-existant fragment hash', function () { + spyOn(window.gl.utils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist'); + spyOn($, 'ajax').and.callFake(function (options) { + options.success({ html: '' }); + }); + + this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); + + expect(window.notes.toggleDiffNote).not.toHaveBeenCalled(); + }); + }); }); }); }).call(window); diff --git a/spec/javascripts/new_branch_spec.js b/spec/javascripts/new_branch_spec.js index 90a429beeca..c57f44dae17 100644 --- a/spec/javascripts/new_branch_spec.js +++ b/spec/javascripts/new_branch_spec.js @@ -1,7 +1,7 @@ /* eslint-disable space-before-function-paren, one-var, no-var, one-var-declaration-per-line, no-return-assign, quotes, max-len */ /* global NewBranchForm */ -require('~/new_branch_form'); +import '~/new_branch_form'; (function() { describe('Branch', function() { diff --git a/spec/javascripts/notebook/cells/markdown_spec.js b/spec/javascripts/notebook/cells/markdown_spec.js index 38c976f38d8..a88e9ed3d99 100644 --- a/spec/javascripts/notebook/cells/markdown_spec.js +++ b/spec/javascripts/notebook/cells/markdown_spec.js @@ -1,8 +1,11 @@ import Vue from 'vue'; import MarkdownComponent from '~/notebook/cells/markdown.vue'; +import katex from 'vendor/katex'; const Component = Vue.extend(MarkdownComponent); +window.katex = katex; + describe('Markdown component', () => { let vm; let cell; @@ -38,4 +41,58 @@ describe('Markdown component', () => { it('renders the markdown HTML', () => { expect(vm.$el.querySelector('.markdown h1')).not.toBeNull(); }); + + describe('katex', () => { + beforeEach(() => { + json = getJSONFixture('blob/notebook/math.json'); + }); + + it('renders multi-line katex', (done) => { + vm = new Component({ + propsData: { + cell: json.cells[0], + }, + }).$mount(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.katex'), + ).not.toBeNull(); + + done(); + }); + }); + + it('renders inline katex', (done) => { + vm = new Component({ + propsData: { + cell: json.cells[1], + }, + }).$mount(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('p:first-child .katex'), + ).not.toBeNull(); + + done(); + }); + }); + + it('renders multiple inline katex', (done) => { + vm = new Component({ + propsData: { + cell: json.cells[1], + }, + }).$mount(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('p:nth-child(2) .katex').length, + ).toBe(4); + + done(); + }); + }); + }); }); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 8fb2216d94b..adb49cc0060 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -13,7 +13,25 @@ import '~/notes'; window.gl = window.gl || {}; gl.utils = gl.utils || {}; + const htmlEscape = (comment) => { + const escapedString = comment.replace(/["&'<>]/g, (a) => { + const escapedToken = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '`': '`' + }[a]; + + return escapedToken; + }); + + return escapedString; + }; + describe('Notes', function() { + const FLASH_TYPE_ALERT = 'alert'; var commentsTemplate = 'issues/issue_with_comment.html.raw'; preloadFixtures(commentsTemplate); @@ -33,7 +51,9 @@ import '~/notes'; }); it('modifies the Markdown field', function() { - $('input[type=checkbox]').attr('checked', true).trigger('change'); + const changeEvent = document.createEvent('HTMLEvents'); + changeEvent.initEvent('change', true, true); + $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent); expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); }); @@ -127,7 +147,6 @@ import '~/notes'; beforeEach(() => { note = { id: 1, - discussion_html: null, valid: true, note: 'heya', html: '<div>heya</div>', @@ -482,11 +501,17 @@ import '~/notes'; }); describe('getFormData', () => { - it('should return form metadata object from form reference', () => { + let $form; + let sampleComment; + + beforeEach(() => { this.notes = new Notes('', []); - const $form = $('form'); - const sampleComment = 'foobar'; + $form = $('form'); + sampleComment = 'foobar'; + }); + + it('should return form metadata object from form reference', () => { $form.find('textarea.js-note-text').val(sampleComment); const { formData, formContent, formAction } = this.notes.getFormData($form); @@ -494,6 +519,18 @@ import '~/notes'; expect(formContent).toEqual(sampleComment); expect(formAction).toEqual($form.attr('action')); }); + + it('should return form metadata with sanitized formContent from form reference', () => { + spyOn(_, 'escape').and.callFake(htmlEscape); + + sampleComment = '<script>alert("Boom!");</script>'; + $form.find('textarea.js-note-text').val(sampleComment); + + const { formContent } = this.notes.getFormData($form); + + expect(_.escape).toHaveBeenCalledWith(sampleComment); + expect(formContent).toEqual('<script>alert("Boom!");</script>'); + }); }); describe('hasSlashCommands', () => { @@ -531,6 +568,23 @@ import '~/notes'; expect(stripedComment).toBe(''); }); +<<<<<<< HEAD + + it('should strip slash commands from the comment but leaves plain comment if it is present', () => { + this.notes = new Notes(); + const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign\nMerging this'; + const stripedComment = this.notes.stripSlashCommands(sampleComment); + + expect(stripedComment).toBe('Merging this'); + }); + + it('should NOT strip string that has slashes within', () => { + this.notes = new Notes(); + const sampleComment = 'http://127.0.0.1:3000/root/gitlab-shell/issues/1'; + const stripedComment = this.notes.stripSlashCommands(sampleComment); + + expect(stripedComment).toBe(sampleComment); +======= it('should strip slash commands from the comment but leaves plain comment if it is present', () => { this.notes = new Notes(); @@ -549,11 +603,40 @@ import '~/notes'; }); }); + describe('getSlashCommandDescription', () => { + const availableSlashCommands = [ + { name: 'close', description: 'Close this issue', params: [] }, + { name: 'title', description: 'Change title', params: [{}] }, + { name: 'estimate', description: 'Set time estimate', params: [{}] } + ]; + + beforeEach(() => { + this.notes = new Notes(); + }); + + it('should return executing slash command description when note has single slash command', () => { + const sampleComment = '/close'; + expect(this.notes.getSlashCommandDescription(sampleComment, availableSlashCommands)).toBe('Applying command to close this issue'); + }); + + it('should return generic multiple slash command description when note has multiple slash commands', () => { + const sampleComment = '/close\n/title [Duplicate] Issue foobar'; + expect(this.notes.getSlashCommandDescription(sampleComment, availableSlashCommands)).toBe('Applying multiple commands'); + }); + + it('should return generic slash command description when available slash commands list is not populated', () => { + const sampleComment = '/close\n/title [Duplicate] Issue foobar'; + expect(this.notes.getSlashCommandDescription(sampleComment)).toBe('Applying command'); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + }); + }); + describe('createPlaceholderNote', () => { const sampleComment = 'foobar'; const uniqueId = 'b1234-a4567'; const currentUsername = 'root'; const currentUserFullname = 'Administrator'; + const currentUserAvatar = 'avatar_url'; beforeEach(() => { this.notes = new Notes('', []); @@ -581,19 +664,24 @@ import '~/notes'; uniqueId, isDiscussionNote: false, currentUsername, - currentUserFullname + currentUserFullname, + currentUserAvatar, }); const $tempNoteHeader = $tempNote.find('.note-header'); expect($tempNote.prop('nodeName')).toEqual('LI'); expect($tempNote.attr('id')).toEqual(uniqueId); + expect($tempNote.hasClass('being-posted')).toBeTruthy(); + expect($tempNote.hasClass('fade-in-half')).toBeTruthy(); $tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() { expect($(this).attr('href')).toEqual(`/${currentUsername}`); }); + expect($tempNote.find('.timeline-icon .avatar').attr('src')).toEqual(currentUserAvatar); expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy(); expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname); expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`); expect($tempNote.find('.note-body .note-text p').text().trim()).toEqual(sampleComment); +<<<<<<< HEAD }); it('should escape HTML characters from note based on form contents', () => { @@ -608,6 +696,8 @@ import '~/notes'; expect(_.escape).toHaveBeenCalledWith(commentWithHtml); expect($tempNote.find('.note-body .note-text p').html()).toEqual('<script>alert("Boom!");</script>'); +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 }); it('should return constructed placeholder element for discussion note based on form contents', () => { @@ -623,5 +713,55 @@ import '~/notes'; expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); }); }); + + describe('createPlaceholderSystemNote', () => { + const sampleCommandDescription = 'Applying command to close this issue'; + const uniqueId = 'b1234-a4567'; + + beforeEach(() => { + this.notes = new Notes('', []); + spyOn(_, 'escape').and.callFake(htmlEscape); + }); + + it('should return constructed placeholder element for system note based on form contents', () => { + const $tempNote = this.notes.createPlaceholderSystemNote({ + formContent: sampleCommandDescription, + uniqueId, + }); + + expect($tempNote.prop('nodeName')).toEqual('LI'); + expect($tempNote.attr('id')).toEqual(uniqueId); + expect($tempNote.hasClass('being-posted')).toBeTruthy(); + expect($tempNote.hasClass('fade-in-half')).toBeTruthy(); + expect($tempNote.find('.timeline-content i').text().trim()).toEqual(sampleCommandDescription); + }); + }); + + describe('appendFlash', () => { + beforeEach(() => { + this.notes = new Notes(); + }); + + it('shows a flash message', () => { + this.notes.addFlash('Error message', FLASH_TYPE_ALERT, this.notes.parentTimeline); + + expect($('.flash-alert').is(':visible')).toBeTruthy(); + }); + }); + + describe('clearFlash', () => { + beforeEach(() => { + $(document).off('ajax:success'); + this.notes = new Notes(); + }); + + it('hides visible flash message', () => { + this.notes.addFlash('Error message 1', FLASH_TYPE_ALERT, this.notes.parentTimeline); + + this.notes.clearFlash(); + + expect($('.flash-alert').is(':visible')).toBeFalsy(); + }); + }); }); }).call(window); diff --git a/spec/javascripts/pager_spec.js b/spec/javascripts/pager_spec.js index d966226909b..1d3e1263371 100644 --- a/spec/javascripts/pager_spec.js +++ b/spec/javascripts/pager_spec.js @@ -1,6 +1,6 @@ /* global fixture */ -require('~/pager'); +import '~/pager'; describe('pager', () => { const Pager = window.Pager; diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js index f033956c071..85bd87318db 100644 --- a/spec/javascripts/pipelines/graph/action_component_spec.js +++ b/spec/javascripts/pipelines/graph/action_component_spec.js @@ -4,7 +4,7 @@ import actionComponent from '~/pipelines/components/graph/action_component.vue'; describe('pipeline graph action component', () => { let component; - beforeEach(() => { + beforeEach((done) => { const ActionComponent = Vue.extend(actionComponent); component = new ActionComponent({ propsData: { @@ -14,6 +14,8 @@ describe('pipeline graph action component', () => { actionIcon: 'icon_action_cancel', }, }).$mount(); + + Vue.nextTick(done); }); it('should render a link', () => { @@ -27,7 +29,7 @@ describe('pipeline graph action component', () => { it('should update bootstrap tooltip when title changes', (done) => { component.tooltipText = 'changed'; - Vue.nextTick(() => { + setTimeout(() => { expect(component.$el.getAttribute('data-original-title')).toBe('changed'); done(); }); diff --git a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js index 14ff1b0d25c..25fd18b197e 100644 --- a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js +++ b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js @@ -4,7 +4,7 @@ import dropdownActionComponent from '~/pipelines/components/graph/dropdown_actio describe('action component', () => { let component; - beforeEach(() => { + beforeEach((done) => { const DropdownActionComponent = Vue.extend(dropdownActionComponent); component = new DropdownActionComponent({ propsData: { @@ -14,6 +14,8 @@ describe('action component', () => { actionIcon: 'icon_action_cancel', }, }).$mount(); + + Vue.nextTick(done); }); it('should render a link', () => { diff --git a/spec/javascripts/pipelines/graph/graph_component_spec.js b/spec/javascripts/pipelines/graph/graph_component_spec.js index 6bd0eb86263..c441e54757f 100644 --- a/spec/javascripts/pipelines/graph/graph_component_spec.js +++ b/spec/javascripts/pipelines/graph/graph_component_spec.js @@ -14,32 +14,49 @@ describe('graph component', () => { describe('while is loading', () => { it('should render a loading icon', () => { - const component = new GraphComponent().$mount('#js-pipeline-graph-vue'); + const component = new GraphComponent({ + propsData: { + isLoading: true, + pipeline: {}, + }, + }).$mount('#js-pipeline-graph-vue'); expect(component.$el.querySelector('.loading-icon')).toBeDefined(); }); }); +<<<<<<< HEAD describe('with a successfull response', () => { const interceptor = (request, next) => { next(request.respondWith(JSON.stringify(graphJSON), { status: 200, })); }; +======= + describe('with data', () => { + it('should render the graph', () => { + const component = new GraphComponent({ + propsData: { + isLoading: false, + pipeline: graphJSON, + }, + }).$mount('#js-pipeline-graph-vue'); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - beforeEach(() => { - Vue.http.interceptors.push(interceptor); - }); + expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true); - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); - }); + expect( + component.$el.querySelector('.stage-column:first-child').classList.contains('no-margin'), + ).toEqual(true); - it('should render the graph', (done) => { - const component = new GraphComponent().$mount('#js-pipeline-graph-vue'); + expect( + component.$el.querySelector('.stage-column:nth-child(2)').classList.contains('left-margin'), + ).toEqual(true); - setTimeout(() => { - expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true); + expect( + component.$el.querySelector('.stage-column:nth-child(2) .build:nth-child(1)').classList.contains('left-connector'), + ).toEqual(true); +<<<<<<< HEAD expect( component.$el.querySelector('.stage-column:first-child').classList.contains('no-margin'), ).toEqual(true); @@ -53,10 +70,11 @@ describe('graph component', () => { ).toEqual(true); expect(component.$el.querySelector('loading-icon')).toBe(null); +======= + expect(component.$el.querySelector('loading-icon')).toBe(null); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - expect(component.$el.querySelector('.stage-column-list')).toBeDefined(); - done(); - }, 0); + expect(component.$el.querySelector('.stage-column-list')).toBeDefined(); }); }); }); diff --git a/spec/javascripts/pipelines/graph/job_component_spec.js b/spec/javascripts/pipelines/graph/job_component_spec.js index 63986b6c0db..e90593e0f40 100644 --- a/spec/javascripts/pipelines/graph/job_component_spec.js +++ b/spec/javascripts/pipelines/graph/job_component_spec.js @@ -27,26 +27,30 @@ describe('pipeline graph job component', () => { }); describe('name with link', () => { - it('should render the job name and status with a link', () => { + it('should render the job name and status with a link', (done) => { const component = new JobComponent({ propsData: { job: mockJob, }, }).$mount(); - const link = component.$el.querySelector('a'); + Vue.nextTick(() => { + const link = component.$el.querySelector('a'); - expect(link.getAttribute('href')).toEqual(mockJob.status.details_path); + expect(link.getAttribute('href')).toEqual(mockJob.status.details_path); - expect( - link.getAttribute('data-original-title'), - ).toEqual(`${mockJob.name} - ${mockJob.status.label}`); + expect( + link.getAttribute('data-original-title'), + ).toEqual(`${mockJob.name} - ${mockJob.status.label}`); - expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined(); + expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined(); - expect( - component.$el.querySelector('.ci-status-text').textContent.trim(), - ).toEqual(mockJob.name); + expect( + component.$el.querySelector('.ci-status-text').textContent.trim(), + ).toEqual(mockJob.name); + + done(); + }); }); }); diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js new file mode 100644 index 00000000000..cecc7ceb53d --- /dev/null +++ b/spec/javascripts/pipelines/header_component_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import headerComponent from '~/pipelines/components/header_component.vue'; +import eventHub from '~/pipelines/event_hub'; + +describe('Pipeline details header', () => { + let HeaderComponent; + let vm; + let props; + + beforeEach(() => { + HeaderComponent = Vue.extend(headerComponent); + + const threeWeeksAgo = new Date(); + threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); + + props = { + pipeline: { + details: { + status: { + group: 'failed', + icon: 'ci-status-failed', + label: 'failed', + text: 'failed', + details_path: 'path', + }, + }, + id: 123, + created_at: threeWeeksAgo.toISOString(), + user: { + web_url: 'path', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatar_url: 'link', + }, + retry_path: 'path', + }, + isLoading: false, + }; + + vm = new HeaderComponent({ propsData: props }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render provided pipeline info', () => { + expect( + vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(), + ).toEqual('failed Pipeline #123 triggered 3 weeks ago by Foo'); + }); + + describe('action buttons', () => { + it('should call postAction when button action is clicked', () => { + eventHub.$on('headerPostAction', (action) => { + expect(action.path).toEqual('path'); + }); + + vm.$el.querySelector('button').click(); + }); + }); +}); diff --git a/spec/javascripts/pipelines/mock_data.js b/spec/javascripts/pipelines/mock_data.js deleted file mode 100644 index 2365a662b9f..00000000000 --- a/spec/javascripts/pipelines/mock_data.js +++ /dev/null @@ -1,107 +0,0 @@ -export default { - pipelines: [{ - id: 115, - user: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - web_url: 'http://localhost:3000/root', - }, - path: '/root/review-app/pipelines/115', - details: { - status: { - icon: 'icon_status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - has_details: true, - details_path: '/root/review-app/pipelines/115', - }, - duration: null, - finished_at: '2017-03-17T19:00:15.996Z', - stages: [{ - name: 'build', - title: 'build: failed', - status: { - icon: 'icon_status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - has_details: true, - details_path: '/root/review-app/pipelines/115#build', - }, - path: '/root/review-app/pipelines/115#build', - dropdown_path: '/root/review-app/pipelines/115/stage.json?stage=build', - }, - { - name: 'review', - title: 'review: skipped', - status: { - icon: 'icon_status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - has_details: true, - details_path: '/root/review-app/pipelines/115#review', - }, - path: '/root/review-app/pipelines/115#review', - dropdown_path: '/root/review-app/pipelines/115/stage.json?stage=review', - }], - artifacts: [], - manual_actions: [{ - name: 'stop_review', - path: '/root/review-app/builds/3766/play', - }], - }, - flags: { - latest: true, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: false, - }, - ref: { - name: 'thisisabranch', - path: '/root/review-app/tree/thisisabranch', - tag: false, - branch: true, - }, - commit: { - id: '9e87f87625b26c42c59a2ee0398f81d20cdfe600', - short_id: '9e87f876', - title: 'Update README.md', - created_at: '2017-03-15T22:58:28.000+00:00', - parent_ids: ['3744f9226e699faec2662a8b267e5d3fd0bfff0e'], - message: 'Update README.md', - author_name: 'Root', - author_email: 'admin@example.com', - authored_date: '2017-03-15T22:58:28.000+00:00', - committer_name: 'Root', - committer_email: 'admin@example.com', - committed_date: '2017-03-15T22:58:28.000+00:00', - author: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - web_url: 'http://localhost:3000/root', - }, - author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - commit_url: 'http://localhost:3000/root/review-app/commit/9e87f87625b26c42c59a2ee0398f81d20cdfe600', - commit_path: '/root/review-app/commit/9e87f87625b26c42c59a2ee0398f81d20cdfe600', - }, - retry_path: '/root/review-app/pipelines/115/retry', - created_at: '2017-03-15T22:58:33.436Z', - updated_at: '2017-03-17T19:00:15.997Z', - }], - count: { - all: 52, - running: 0, - pending: 0, - finished: 52, - }, -}; diff --git a/spec/javascripts/pipelines/pipeline_details_mediator_spec.js b/spec/javascripts/pipelines/pipeline_details_mediator_spec.js new file mode 100644 index 00000000000..9fec2f61f78 --- /dev/null +++ b/spec/javascripts/pipelines/pipeline_details_mediator_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import PipelineMediator from '~/pipelines/pipeline_details_mediatior'; + +describe('PipelineMdediator', () => { + let mediator; + beforeEach(() => { + mediator = new PipelineMediator({ endpoint: 'foo' }); + }); + + it('should set defaults', () => { + expect(mediator.options).toEqual({ endpoint: 'foo' }); + expect(mediator.state.isLoading).toEqual(false); + expect(mediator.store).toBeDefined(); + expect(mediator.service).toBeDefined(); + }); + + describe('request and store data', () => { + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify({ foo: 'bar' }), { + status: 200, + })); + }; + + beforeEach(() => { + Vue.http.interceptors.push(interceptor); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptor, interceptor); + }); + + it('should store received data', (done) => { + mediator.fetchPipeline(); + + setTimeout(() => { + expect(mediator.store.state.pipeline).toEqual({ foo: 'bar' }); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/pipelines/pipeline_store_spec.js b/spec/javascripts/pipelines/pipeline_store_spec.js new file mode 100644 index 00000000000..85d13445b01 --- /dev/null +++ b/spec/javascripts/pipelines/pipeline_store_spec.js @@ -0,0 +1,27 @@ +import PipelineStore from '~/pipelines/stores/pipeline_store'; + +describe('Pipeline Store', () => { + let store; + + beforeEach(() => { + store = new PipelineStore(); + }); + + it('should set defaults', () => { + expect(store.state).toEqual({ pipeline: {} }); + expect(store.state.pipeline).toEqual({}); + }); + + describe('storePipeline', () => { + it('should store empty object if none is provided', () => { + store.storePipeline(); + + expect(store.state.pipeline).toEqual({}); + }); + + it('should store received object', () => { + store.storePipeline({ foo: 'bar' }); + expect(store.state.pipeline).toEqual({ foo: 'bar' }); + }); + }); +}); diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js index 53931d67ad7..594a9856d2c 100644 --- a/spec/javascripts/pipelines/pipeline_url_spec.js +++ b/spec/javascripts/pipelines/pipeline_url_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import pipelineUrlComp from '~/pipelines/components/pipeline_url'; +import pipelineUrlComp from '~/pipelines/components/pipeline_url.vue'; describe('Pipeline Url Component', () => { let PipelineUrlComponent; @@ -47,6 +47,7 @@ describe('Pipeline Url Component', () => { web_url: '/', name: 'foo', avatar_url: '/', + path: '/', }, }, }; @@ -60,7 +61,7 @@ describe('Pipeline Url Component', () => { expect( component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'), ).toEqual(mockData.pipeline.user.web_url); - expect(image.getAttribute('title')).toEqual(mockData.pipeline.user.name); + expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name); expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url); }); diff --git a/spec/javascripts/pipelines/pipelines_spec.js b/spec/javascripts/pipelines/pipelines_spec.js index e9c05f74ce6..3a56156358b 100644 --- a/spec/javascripts/pipelines/pipelines_spec.js +++ b/spec/javascripts/pipelines/pipelines_spec.js @@ -1,15 +1,20 @@ import Vue from 'vue'; import pipelinesComp from '~/pipelines/pipelines'; import Store from '~/pipelines/stores/pipelines_store'; -import pipelinesData from './mock_data'; describe('Pipelines', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + preloadFixtures('static/pipelines.html.raw'); + preloadFixtures(jsonFixtureName); let PipelinesComponent; + let pipeline; beforeEach(() => { loadFixtures('static/pipelines.html.raw'); + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); PipelinesComponent = Vue.extend(pipelinesComp); }); @@ -17,7 +22,7 @@ describe('Pipelines', () => { describe('successfull request', () => { describe('with pipelines', () => { const pipelinesInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify(pipelinesData), { + next(request.respondWith(JSON.stringify(pipeline), { status: 200, })); }; diff --git a/spec/javascripts/pretty_time_spec.js b/spec/javascripts/pretty_time_spec.js index a4662cfb557..de99e7e3894 100644 --- a/spec/javascripts/pretty_time_spec.js +++ b/spec/javascripts/pretty_time_spec.js @@ -1,4 +1,4 @@ -require('~/lib/utils/pretty_time'); +import '~/lib/utils/pretty_time'; (() => { const prettyTime = gl.utils.prettyTime; diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js index 3a1d4e2440f..3dba2e817ff 100644 --- a/spec/javascripts/project_title_spec.js +++ b/spec/javascripts/project_title_spec.js @@ -1,12 +1,11 @@ /* eslint-disable space-before-function-paren, no-unused-expressions, no-return-assign, no-param-reassign, no-var, new-cap, wrap-iife, no-unused-vars, quotes, jasmine/no-expect-in-setup-teardown, max-len */ /* global Project */ -require('select2/select2.js'); -require('~/lib/utils/type_utility'); -require('~/gl_dropdown'); -require('~/api'); -require('~/project_select'); -require('~/project'); +import 'select2/select2'; +import '~/gl_dropdown'; +import '~/api'; +import '~/project_select'; +import '~/project'; (function() { describe('Project Title', function() { diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js index b31a7c28ebe..c82658b9262 100644 --- a/spec/javascripts/raven/raven_config_spec.js +++ b/spec/javascripts/raven/raven_config_spec.js @@ -140,24 +140,6 @@ describe('RavenConfig', () => { }); }); - describe('bindRavenErrors', () => { - let $document; - let $; - - beforeEach(() => { - $document = jasmine.createSpyObj('$document', ['on']); - $ = jasmine.createSpy('$').and.returnValue($document); - - window.$ = $; - - RavenConfig.bindRavenErrors(); - }); - - it('should call .on', function () { - expect($document.on).toHaveBeenCalledWith('ajaxError.raven', RavenConfig.handleRavenErrors); - }); - }); - describe('handleRavenErrors', () => { let event; let req; diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js index aaf058bd755..a53f58b5d0d 100644 --- a/spec/javascripts/search_autocomplete_spec.js +++ b/spec/javascripts/search_autocomplete_spec.js @@ -1,10 +1,9 @@ /* eslint-disable space-before-function-paren, max-len, no-var, one-var, one-var-declaration-per-line, no-unused-expressions, consistent-return, no-param-reassign, default-case, no-return-assign, comma-dangle, object-shorthand, prefer-template, quotes, new-parens, vars-on-top, new-cap, max-len */ -require('~/gl_dropdown'); -require('~/search_autocomplete'); -require('~/lib/utils/common_utils'); -require('~/lib/utils/type_utility'); -require('vendor/fuzzaldrin-plus'); +import '~/gl_dropdown'; +import '~/search_autocomplete'; +import '~/lib/utils/common_utils'; +import 'vendor/fuzzaldrin-plus'; (function() { var addBodyAttributes, assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget; diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index 9e19dabd0e3..3515dfbc60b 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -1,8 +1,8 @@ /* eslint-disable space-before-function-paren, no-return-assign, no-var, quotes */ /* global ShortcutsIssuable */ -require('~/copy_as_gfm'); -require('~/shortcuts_issuable'); +import '~/copy_as_gfm'; +import '~/shortcuts_issuable'; (function() { describe('ShortcutsIssuable', function() { @@ -13,7 +13,7 @@ require('~/shortcuts_issuable'); document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); this.shortcut = new ShortcutsIssuable(); }); - describe('#replyWithSelectedText', function() { + describe('replyWithSelectedText', function() { var stubSelection; // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML. stubSelection = function(html) { diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js index 8ef6c3907dc..929ba75e67d 100644 --- a/spec/javascripts/sidebar/sidebar_assignees_spec.js +++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js @@ -24,6 +24,7 @@ describe('sidebar assignees', () => { SidebarService.singleton = null; SidebarStore.singleton = null; SidebarMediator.singleton = null; + Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor); }); it('calls the mediator when saves the assignees', () => { diff --git a/spec/javascripts/sidebar/sidebar_bundle_spec.js b/spec/javascripts/sidebar/sidebar_bundle_spec.js deleted file mode 100644 index 7760b34e071..00000000000 --- a/spec/javascripts/sidebar/sidebar_bundle_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue'; -import SidebarBundleDomContentLoaded from '~/sidebar/sidebar_bundle'; -import SidebarTimeTracking from '~/sidebar/components/time_tracking/sidebar_time_tracking'; -import SidebarMediator from '~/sidebar/sidebar_mediator'; -import SidebarService from '~/sidebar/services/sidebar_service'; -import SidebarStore from '~/sidebar/stores/sidebar_store'; -import Mock from './mock_data'; - -describe('sidebar bundle', () => { - gl.sidebarOptions = Mock.mediator; - - beforeEach(() => { - spyOn(SidebarTimeTracking.methods, 'listenForSlashCommands').and.callFake(() => { }); - preloadFixtures('issues/open-issue.html.raw'); - Vue.http.interceptors.push(Mock.sidebarMockInterceptor); - loadFixtures('issues/open-issue.html.raw'); - spyOn(Vue.prototype, '$mount'); - SidebarBundleDomContentLoaded(); - this.mediator = new SidebarMediator(); - }); - - afterEach(() => { - SidebarService.singleton = null; - SidebarStore.singleton = null; - SidebarMediator.singleton = null; - }); - - it('the mediator should be already defined with some data', () => { - SidebarBundleDomContentLoaded(); - - expect(this.mediator.store).toBeDefined(); - expect(this.mediator.service).toBeDefined(); - expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser); - expect(this.mediator.store.rootPath).toEqual(Mock.mediator.rootPath); - expect(this.mediator.store.endPoint).toEqual(Mock.mediator.endPoint); - expect(this.mediator.store.editable).toEqual(Mock.mediator.editable); - }); - - it('the sidebar time tracking and assignees components to have been mounted', () => { - expect(Vue.prototype.$mount).toHaveBeenCalledTimes(2); - }); -}); diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js index 2b00fa17334..e246f41ee82 100644 --- a/spec/javascripts/sidebar/sidebar_mediator_spec.js +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -14,6 +14,7 @@ describe('Sidebar mediator', () => { SidebarService.singleton = null; SidebarStore.singleton = null; SidebarMediator.singleton = null; + Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor); }); it('assigns yourself ', () => { diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js index d41162096a6..91a4dd669a7 100644 --- a/spec/javascripts/sidebar/sidebar_service_spec.js +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -10,6 +10,7 @@ describe('Sidebar service', () => { afterEach(() => { SidebarService.singleton = null; + Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor); }); it('gets the data', (done) => { diff --git a/spec/javascripts/signin_tabs_memoizer_spec.js b/spec/javascripts/signin_tabs_memoizer_spec.js index 5b4f5933b34..0a32797c3e2 100644 --- a/spec/javascripts/signin_tabs_memoizer_spec.js +++ b/spec/javascripts/signin_tabs_memoizer_spec.js @@ -1,6 +1,6 @@ import AccessorUtilities from '~/lib/utils/accessor'; -require('~/signin_tabs_memoizer'); +import '~/signin_tabs_memoizer'; ((global) => { describe('SigninTabsMemoizer', () => { diff --git a/spec/javascripts/smart_interval_spec.js b/spec/javascripts/smart_interval_spec.js index 4366ec2a5b8..7833bf3fb04 100644 --- a/spec/javascripts/smart_interval_spec.js +++ b/spec/javascripts/smart_interval_spec.js @@ -1,4 +1,4 @@ -require('~/smart_interval'); +import '~/smart_interval'; (() => { const DEFAULT_MAX_INTERVAL = 100; diff --git a/spec/javascripts/syntax_highlight_spec.js b/spec/javascripts/syntax_highlight_spec.js index cea223bd243..946f98379ce 100644 --- a/spec/javascripts/syntax_highlight_spec.js +++ b/spec/javascripts/syntax_highlight_spec.js @@ -1,6 +1,6 @@ /* eslint-disable space-before-function-paren, no-var, no-return-assign, quotes */ -require('~/syntax_highlight'); +import '~/syntax_highlight'; (function() { describe('Syntax Highlighter', function() { diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index a22014879e8..13827a26571 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -1,13 +1,15 @@ -// enable test fixtures -require('jasmine-jquery'); +import $ from 'jquery'; +import _ from 'underscore'; +import 'jasmine-jquery'; +import '~/commons'; -jasmine.getFixtures().fixturesPath = 'base/spec/javascripts/fixtures'; -jasmine.getJSONFixtures().fixturesPath = 'base/spec/javascripts/fixtures'; +// enable test fixtures +jasmine.getFixtures().fixturesPath = '/base/spec/javascripts/fixtures'; +jasmine.getJSONFixtures().fixturesPath = '/base/spec/javascripts/fixtures'; -// include common libraries -require('~/commons/index.js'); -window.$ = window.jQuery = require('jquery'); -window._ = require('underscore'); +// globalize common libraries +window.$ = window.jQuery = $; +window._ = _; // stub expected globals window.gl = window.gl || {}; diff --git a/spec/javascripts/todos_spec.js b/spec/javascripts/todos_spec.js index 66e4fbd6304..cd74aba4a4e 100644 --- a/spec/javascripts/todos_spec.js +++ b/spec/javascripts/todos_spec.js @@ -1,5 +1,5 @@ -require('~/todos'); -require('~/lib/utils/common_utils'); +import '~/todos'; +import '~/lib/utils/common_utils'; describe('Todos', () => { preloadFixtures('todos/todos.html.raw'); diff --git a/spec/javascripts/u2f/authenticate_spec.js b/spec/javascripts/u2f/authenticate_spec.js index af2d02b6b29..a160c86308d 100644 --- a/spec/javascripts/u2f/authenticate_spec.js +++ b/spec/javascripts/u2f/authenticate_spec.js @@ -2,11 +2,11 @@ /* global MockU2FDevice */ /* global U2FAuthenticate */ -require('~/u2f/authenticate'); -require('~/u2f/util'); -require('~/u2f/error'); -require('vendor/u2f'); -require('./mock_u2f_device'); +import '~/u2f/authenticate'; +import '~/u2f/util'; +import '~/u2f/error'; +import 'vendor/u2f'; +import './mock_u2f_device'; (function() { describe('U2FAuthenticate', function() { diff --git a/spec/javascripts/u2f/mock_u2f_device.js b/spec/javascripts/u2f/mock_u2f_device.js index 6677fe9c1ee..4eb8ad3d9e4 100644 --- a/spec/javascripts/u2f/mock_u2f_device.js +++ b/spec/javascripts/u2f/mock_u2f_device.js @@ -1,12 +1,10 @@ /* eslint-disable space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-expressions, no-return-assign, no-param-reassign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.MockU2FDevice = (function() { function MockU2FDevice() { - this.respondToAuthenticateRequest = bind(this.respondToAuthenticateRequest, this); - this.respondToRegisterRequest = bind(this.respondToRegisterRequest, this); + this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this); + this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this); window.u2f || (window.u2f = {}); window.u2f.register = (function(_this) { return function(appId, registerRequests, signRequests, callback) { diff --git a/spec/javascripts/u2f/register_spec.js b/spec/javascripts/u2f/register_spec.js index 3960759f7cb..a445c80f2af 100644 --- a/spec/javascripts/u2f/register_spec.js +++ b/spec/javascripts/u2f/register_spec.js @@ -2,11 +2,11 @@ /* global MockU2FDevice */ /* global U2FRegister */ -require('~/u2f/register'); -require('~/u2f/util'); -require('~/u2f/error'); -require('vendor/u2f'); -require('./mock_u2f_device'); +import '~/u2f/register'; +import '~/u2f/util'; +import '~/u2f/error'; +import 'vendor/u2f'; +import './mock_u2f_device'; (function() { describe('U2FRegister', function() { diff --git a/spec/javascripts/version_check_image_spec.js b/spec/javascripts/version_check_image_spec.js index 464c1fce210..9637bd0414a 100644 --- a/spec/javascripts/version_check_image_spec.js +++ b/spec/javascripts/version_check_image_spec.js @@ -1,9 +1,8 @@ -const ClassSpecHelper = require('./helpers/class_spec_helper'); -const VersionCheckImage = require('~/version_check_image'); -require('jquery'); +import VersionCheckImage from '~/version_check_image'; +import ClassSpecHelper from './helpers/class_spec_helper'; describe('VersionCheckImage', function () { - describe('.bindErrorEvent', function () { + describe('bindErrorEvent', function () { ClassSpecHelper.itShouldBeAStaticMethod(VersionCheckImage, 'bindErrorEvent'); beforeEach(function () { diff --git a/spec/javascripts/visibility_select_spec.js b/spec/javascripts/visibility_select_spec.js index 9727c03c91e..c2eaea7c2ed 100644 --- a/spec/javascripts/visibility_select_spec.js +++ b/spec/javascripts/visibility_select_spec.js @@ -1,4 +1,4 @@ -require('~/visibility_select'); +import '~/visibility_select'; (() => { const VisibilitySelect = gl.VisibilitySelect; @@ -22,7 +22,7 @@ require('~/visibility_select'); spyOn(Element.prototype, 'querySelector').and.callFake(selector => mockElements[selector]); }); - describe('#constructor', function () { + describe('constructor', function () { beforeEach(function () { this.visibilitySelect = new VisibilitySelect(mockElements.container); }); @@ -48,7 +48,7 @@ require('~/visibility_select'); }); }); - describe('#init', function () { + describe('init', function () { describe('if there is a select', function () { beforeEach(function () { this.visibilitySelect = new VisibilitySelect(mockElements.container); @@ -85,7 +85,7 @@ require('~/visibility_select'); }); }); - describe('#updateHelpText', function () { + describe('updateHelpText', function () { beforeEach(function () { this.visibilitySelect = new VisibilitySelect(mockElements.container); this.visibilitySelect.init(); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js index da9dff18ada..39a8c680fb4 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js @@ -7,6 +7,21 @@ const url = '/root/acets-review-apps/environments/15/deployments/1/metrics'; const metricsMockData = { success: true, metrics: { +<<<<<<< HEAD +======= + memory_before: [ + { + metric: {}, + value: [1495785220.607, '9572875.906976745'], + }, + ], + memory_after: [ + { + metric: {}, + value: [1495787020.607, '4485853.130206379'], + }, + ], +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 memory_values: [ { metric: {}, @@ -39,7 +54,11 @@ const createComponent = () => { const messages = { loadingMetrics: 'Loading deployment statistics.', +<<<<<<< HEAD hasMetrics: 'Deployment memory usage:', +======= + hasMetrics: 'Memory usage unchanged from 0MB to 0MB', +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 loadFailed: 'Failed to load deployment statistics.', metricsUnavailable: 'Deployment statistics are not available currently.', }; @@ -89,6 +108,7 @@ describe('MemoryUsage', () => { }); }); +<<<<<<< HEAD describe('methods', () => { const { metrics, deployment_time } = metricsMockData; @@ -96,10 +116,57 @@ describe('MemoryUsage', () => { it('should populate sparkline graph', () => { vm.computeGraphData(metrics, deployment_time); const { hasMetrics, memoryMetrics, deploymentTime } = vm; +======= + describe('computed', () => { + describe('memoryChangeType', () => { + it('should return "increased" if memoryFrom value is less than memoryTo value', () => { + vm.memoryFrom = 4.28; + vm.memoryTo = 9.13; + + expect(vm.memoryChangeType).toEqual('increased'); + }); + + it('should return "decreased" if memoryFrom value is less than memoryTo value', () => { + vm.memoryFrom = 9.13; + vm.memoryTo = 4.28; + + expect(vm.memoryChangeType).toEqual('decreased'); + }); + + it('should return "unchanged" if memoryFrom value equal to memoryTo value', () => { + vm.memoryFrom = 1; + vm.memoryTo = 1; + + expect(vm.memoryChangeType).toEqual('unchanged'); + }); + }); + }); + + describe('methods', () => { + const { metrics, deployment_time } = metricsMockData; + + describe('getMegabytes', () => { + it('should return Megabytes from provided Bytes value', () => { + const memoryInBytes = '9572875.906976745'; + + expect(vm.getMegabytes(memoryInBytes)).toEqual('9.13'); + }); + }); + + describe('computeGraphData', () => { + it('should populate sparkline graph', () => { + vm.computeGraphData(metrics, deployment_time); + const { hasMetrics, memoryMetrics, deploymentTime, memoryFrom, memoryTo } = vm; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(hasMetrics).toBeTruthy(); expect(memoryMetrics.length > 0).toBeTruthy(); expect(deploymentTime).toEqual(deployment_time); +<<<<<<< HEAD +======= + expect(memoryFrom).toEqual('9.13'); + expect(memoryTo).toEqual('4.28'); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 }); }); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js index d40c67b189d..079086451b6 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js @@ -4,6 +4,7 @@ import nothingToMergeComponent from '~/vue_merge_request_widget/components/state describe('MRWidgetNothingToMerge', () => { describe('template', () => { const Component = Vue.extend(nothingToMergeComponent); +<<<<<<< HEAD const vm = new Component({ el: document.createElement('div'), }); @@ -13,5 +14,28 @@ describe('MRWidgetNothingToMerge', () => { expect(vm.$el.innerText).toContain('There is nothing to merge from source branch into target branch.'); expect(vm.$el.innerText).toContain('Please push new commits or use a different branch.'); }); +======= + const newBlobPath = '/foo'; + const vm = new Component({ + el: document.createElement('div'), + propsData: { + mr: { newBlobPath }, + }, + }); + + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('a').href).toContain(newBlobPath); + expect(vm.$el.innerText).toContain('Currently there are no changes in this merge request\'s source branch'); + expect(vm.$el.innerText).toContain('Please push new commits or use a different branch.'); + }); + + it('should not show new blob link if there is no link available', () => { + vm.mr.newBlobPath = null; + Vue.nextTick(() => { + expect(vm.$el.querySelector('a')).toEqual(null); + }); + }); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 }); }); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index d043ad38b8b..eae27d5b813 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -5,7 +5,11 @@ import * as simplePoll from '~/lib/utils/simple_poll'; const commitMessage = 'This is the commit message'; const commitMessageWithDescription = 'This is the commit message description'; +<<<<<<< HEAD const createComponent = () => { +======= +const createComponent = (customConfig = {}) => { +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 const Component = Vue.extend(readyToMergeComponent); const mr = { isPipelineActive: false, @@ -17,8 +21,17 @@ const createComponent = () => { sha: '12345678', commitMessage, commitMessageWithDescription, +<<<<<<< HEAD }; +======= + shouldRemoveSourceBranch: true, + canRemoveSourceBranch: false, + }; + + Object.assign(mr, customConfig.mr); + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 const service = { merge() {}, poll() {}, @@ -51,7 +64,10 @@ describe('MRWidgetReadyToMerge', () => { describe('data', () => { it('should have default data', () => { +<<<<<<< HEAD expect(vm.removeSourceBranch).toBeTruthy(true); +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(vm.mergeWhenBuildSucceeds).toBeFalsy(); expect(vm.useCommitMessageWithDescription).toBeFalsy(); expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); @@ -166,6 +182,39 @@ describe('MRWidgetReadyToMerge', () => { expect(vm.isMergeButtonDisabled).toBeTruthy(); }); }); +<<<<<<< HEAD +======= + + describe('Remove source branch checkbox', () => { + describe('when user can merge but cannot delete branch', () => { + it('isRemoveSourceBranchButtonDisabled should be true', () => { + expect(vm.isRemoveSourceBranchButtonDisabled).toBe(true); + }); + + it('should be disabled in the rendered output', () => { + const checkboxElement = vm.$el.querySelector('#remove-source-branch-input'); + expect(checkboxElement.getAttribute('disabled')).toBe('disabled'); + }); + }); + + describe('when user can merge and can delete branch', () => { + beforeEach(() => { + this.customVm = createComponent({ + mr: { canRemoveSourceBranch: true }, + }); + }); + + it('isRemoveSourceBranchButtonDisabled should be false', () => { + expect(this.customVm.isRemoveSourceBranchButtonDisabled).toBe(false); + }); + + it('should be enabled in rendered output', () => { + const checkboxElement = this.customVm.$el.querySelector('#remove-source-branch-input'); + expect(checkboxElement.getAttribute('disabled')).toBeNull(); + }); + }); + }); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 }); describe('methods', () => { diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index bdc18243a15..795d646b719 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -2,6 +2,10 @@ import Vue from 'vue'; import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options'; import eventHub from '~/vue_merge_request_widget/event_hub'; +<<<<<<< HEAD +======= +import notify from '~/lib/utils/notify'; +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 import mockData from './mock_data'; const createComponent = () => { @@ -107,6 +111,11 @@ describe('mrWidgetOptions', () => { it('should tell service to check status', (done) => { spyOn(vm.service, 'checkStatus').and.returnValue(returnPromise(mockData)); spyOn(vm.mr, 'setData'); +<<<<<<< HEAD +======= + spyOn(vm, 'handleNotification'); + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 let isCbExecuted = false; const cb = () => { isCbExecuted = true; @@ -117,6 +126,10 @@ describe('mrWidgetOptions', () => { setTimeout(() => { expect(vm.service.checkStatus).toHaveBeenCalled(); expect(vm.mr.setData).toHaveBeenCalled(); +<<<<<<< HEAD +======= + expect(vm.handleNotification).toHaveBeenCalledWith(mockData); +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(isCbExecuted).toBeTruthy(); done(); }, 333); @@ -254,6 +267,42 @@ describe('mrWidgetOptions', () => { }); }); +<<<<<<< HEAD +======= + describe('handleNotification', () => { + const data = { + ci_status: 'running', + title: 'title', + pipeline: { details: { status: { label: 'running-label' } } }, + }; + + beforeEach(() => { + spyOn(notify, 'notifyMe'); + + vm.mr.ciStatus = 'failed'; + vm.mr.gitlabLogo = 'logo.png'; + }); + + it('should call notifyMe', () => { + vm.handleNotification(data); + + expect(notify.notifyMe).toHaveBeenCalledWith( + 'Pipeline running-label', + 'Pipeline running-label for "title"', + 'logo.png', + ); + }); + + it('should not call notifyMe if the status has not changed', () => { + vm.mr.ciStatus = data.ci_status; + + vm.handleNotification(data); + + expect(notify.notifyMe).not.toHaveBeenCalled(); + }); + }); + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 describe('resumePolling', () => { it('should call stopTimer on pollingInterval', () => { spyOn(vm.pollingInterval, 'resume'); diff --git a/spec/javascripts/vue_shared/components/commit_spec.js b/spec/javascripts/vue_shared/components/commit_spec.js index 01dabf5320e..540245fe71e 100644 --- a/spec/javascripts/vue_shared/components/commit_spec.js +++ b/spec/javascripts/vue_shared/components/commit_spec.js @@ -24,6 +24,7 @@ describe('Commit component', () => { author: { avatar_url: 'https://gitlab.com/uploads/system/user/avatar/300478/avatar.png', web_url: 'https://gitlab.com/jschatz1', + path: '/jschatz1', username: 'jschatz1', }, }, @@ -46,6 +47,7 @@ describe('Commit component', () => { author: { avatar_url: 'https://gitlab.com/uploads/system/user/avatar/300478/avatar.png', web_url: 'https://gitlab.com/jschatz1', + path: '/jschatz1', username: 'jschatz1', }, commitIconSvg: '<svg></svg>', @@ -61,16 +63,16 @@ describe('Commit component', () => { }); it('should render a link to the ref url', () => { - expect(component.$el.querySelector('.branch-name').getAttribute('href')).toEqual(props.commitRef.ref_url); + expect(component.$el.querySelector('.ref-name').getAttribute('href')).toEqual(props.commitRef.ref_url); }); it('should render the ref name', () => { - expect(component.$el.querySelector('.branch-name').textContent).toContain(props.commitRef.name); + expect(component.$el.querySelector('.ref-name').textContent).toContain(props.commitRef.name); }); it('should render the commit short sha with a link to the commit url', () => { - expect(component.$el.querySelector('.commit-id').getAttribute('href')).toEqual(props.commitUrl); - expect(component.$el.querySelector('.commit-id').textContent).toContain(props.shortSha); + expect(component.$el.querySelector('.commit-sha').getAttribute('href')).toEqual(props.commitUrl); + expect(component.$el.querySelector('.commit-sha').textContent).toContain(props.shortSha); }); it('should render the given commitIconSvg', () => { @@ -81,12 +83,12 @@ describe('Commit component', () => { it('should render a link to the author profile', () => { expect( component.$el.querySelector('.commit-title .avatar-image-container').getAttribute('href'), - ).toEqual(props.author.web_url); + ).toEqual(props.author.path); }); it('Should render the author avatar with title and alt attributes', () => { expect( - component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('title'), + component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('data-original-title'), ).toContain(props.author.username); expect( component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('alt'), diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js new file mode 100644 index 00000000000..2b51c89f311 --- /dev/null +++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import headerCi from '~/vue_shared/components/header_ci_component.vue'; + +describe('Header CI Component', () => { + let HeaderCi; + let vm; + let props; + + beforeEach(() => { + HeaderCi = Vue.extend(headerCi); + + props = { + status: { + group: 'failed', + icon: 'ci-status-failed', + label: 'failed', + text: 'failed', + details_path: 'path', + }, + itemName: 'job', + itemId: 123, + time: '2017-05-08T14:57:39.781Z', + user: { + web_url: 'path', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatar_url: 'link', + }, + actions: [ + { + label: 'Retry', + path: 'path', + type: 'button', + cssClass: 'btn', + isLoading: false, + }, + { + label: 'Go', + path: 'path', + type: 'link', + cssClass: 'link', + isLoading: false, + }, + ], + }; + + vm = new HeaderCi({ + propsData: props, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render status badge', () => { + expect(vm.$el.querySelector('.ci-failed')).toBeDefined(); + expect(vm.$el.querySelector('.ci-status-icon-failed svg')).toBeDefined(); + expect( + vm.$el.querySelector('.ci-failed').getAttribute('href'), + ).toEqual(props.status.details_path); + }); + + it('should render item name and id', () => { + expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123'); + }); + + it('should render timeago date', () => { + expect(vm.$el.querySelector('time')).toBeDefined(); + }); + + it('should render user icon and name', () => { + expect(vm.$el.querySelector('.js-user-link').textContent.trim()).toEqual(props.user.name); + }); + + it('should render provided actions', () => { + expect(vm.$el.querySelector('.btn').tagName).toEqual('BUTTON'); + expect(vm.$el.querySelector('.btn').textContent.trim()).toEqual(props.actions[0].label); + expect(vm.$el.querySelector('.link').tagName).toEqual('A'); + expect(vm.$el.querySelector('.link').textContent.trim()).toEqual(props.actions[1].label); + expect(vm.$el.querySelector('.link').getAttribute('href')).toEqual(props.actions[0].path); + }); + + it('should show loading icon', (done) => { + vm.actions[0].isLoading = true; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn .fa-spinner').getAttribute('style')).toEqual(''); + done(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/loading_icon_spec.js b/spec/javascripts/vue_shared/components/loading_icon_spec.js new file mode 100644 index 00000000000..1baf3537741 --- /dev/null +++ b/spec/javascripts/vue_shared/components/loading_icon_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import loadingIcon from '~/vue_shared/components/loading_icon.vue'; + +describe('Loading Icon Component', () => { + let LoadingIconComponent; + + beforeEach(() => { + LoadingIconComponent = Vue.extend(loadingIcon); + }); + + it('should render a spinner font awesome icon', () => { + const component = new LoadingIconComponent().$mount(); + + expect( + component.$el.querySelector('i').getAttribute('class'), + ).toEqual('fa fa-spin fa-spinner fa-1x'); + + expect(component.$el.tagName).toEqual('DIV'); + expect(component.$el.classList.contains('text-center')).toEqual(true); + }); + + it('should render accessibility attributes', () => { + const component = new LoadingIconComponent().$mount(); + + const icon = component.$el.querySelector('i'); + expect(icon.getAttribute('aria-hidden')).toEqual('true'); + expect(icon.getAttribute('aria-label')).toEqual('Loading'); + }); + + it('should render the provided label', () => { + const component = new LoadingIconComponent({ + propsData: { + label: 'This is a loading icon', + }, + }).$mount(); + + expect( + component.$el.querySelector('i').getAttribute('aria-label'), + ).toEqual('This is a loading icon'); + }); + + it('should render the provided size', () => { + const component = new LoadingIconComponent({ + propsData: { + size: '2', + }, + }).$mount(); + + expect( + component.$el.querySelector('i').classList.contains('fa-2x'), + ).toEqual(true); + }); +}); diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js new file mode 100644 index 00000000000..4bbaff561fc --- /dev/null +++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js @@ -0,0 +1,121 @@ +import Vue from 'vue'; +import fieldComponent from '~/vue_shared/components/markdown/field.vue'; + +describe('Markdown field component', () => { + let vm; + + beforeEach(() => { + vm = new Vue({ + render(createElement) { + return createElement( + fieldComponent, + { + props: { + markdownPreviewUrl: '/preview', + markdownDocs: '/docs', + }, + }, + [ + createElement('textarea', { + slot: 'textarea', + }), + ], + ); + }, + }); + }); + + it('creates a new instance of GL form', (done) => { + spyOn(gl, 'GLForm'); + vm.$mount(); + + Vue.nextTick(() => { + expect( + gl.GLForm, + ).toHaveBeenCalled(); + + done(); + }); + }); + + describe('mounted', () => { + beforeEach((done) => { + vm.$mount(); + + Vue.nextTick(done); + }); + + it('renders textarea inside backdrop', () => { + expect( + vm.$el.querySelector('.zen-backdrop textarea'), + ).not.toBeNull(); + }); + + describe('markdown preview', () => { + let previewLink; + + beforeEach(() => { + spyOn(Vue.http, 'post').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + body: '<p>markdown preview</p>', + }; + }, + }); + })); + + previewLink = vm.$el.querySelector('.nav-links li:nth-child(2) a'); + }); + + it('sets preview link as active', (done) => { + previewLink.click(); + + Vue.nextTick(() => { + expect( + previewLink.parentNode.classList.contains('active'), + ).toBeTruthy(); + + done(); + }); + }); + + it('shows preview loading text', (done) => { + previewLink.click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.md-preview').textContent.trim(), + ).toContain('Loading...'); + + done(); + }); + }); + + it('renders markdown preview', (done) => { + previewLink.click(); + + setTimeout(() => { + expect( + vm.$el.querySelector('.md-preview').innerHTML, + ).toContain('<p>markdown preview</p>'); + + done(); + }); + }); + + it('renders GFM with jQuery', (done) => { + spyOn($.fn, 'renderGFM'); + previewLink.click(); + + setTimeout(() => { + expect( + $.fn.renderGFM, + ).toHaveBeenCalled(); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/markdown/header_spec.js b/spec/javascripts/vue_shared/components/markdown/header_spec.js new file mode 100644 index 00000000000..7110ff36937 --- /dev/null +++ b/spec/javascripts/vue_shared/components/markdown/header_spec.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import headerComponent from '~/vue_shared/components/markdown/header.vue'; + +describe('Markdown field header component', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(headerComponent); + + vm = new Component({ + propsData: { + previewMarkdown: false, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders markdown buttons', () => { + expect( + vm.$el.querySelectorAll('.js-md').length, + ).toBe(7); + }); + + it('renders `write` link as active when previewMarkdown is false', () => { + expect( + vm.$el.querySelector('li:nth-child(1)').classList.contains('active'), + ).toBeTruthy(); + }); + + it('renders `preview` link as active when previewMarkdown is true', (done) => { + vm.previewMarkdown = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('li:nth-child(2)').classList.contains('active'), + ).toBeTruthy(); + + done(); + }); + }); + + it('emits toggle markdown event when clicking preview', () => { + spyOn(vm, '$emit'); + + vm.$el.querySelector('li:nth-child(2) a').click(); + + expect( + vm.$emit, + ).toHaveBeenCalledWith('toggle-markdown'); + }); + + it('blurs preview link after click', (done) => { + const link = vm.$el.querySelector('li:nth-child(2) a'); + spyOn(HTMLElement.prototype, 'blur'); + + link.click(); + + setTimeout(() => { + expect( + link.blur, + ).toHaveBeenCalled(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js index 699625cdbb7..67419cfcbea 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js +++ b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js @@ -1,27 +1,47 @@ import Vue from 'vue'; import tableRowComp from '~/vue_shared/components/pipelines_table_row'; -import pipeline from '../../commit/pipelines/mock_data'; describe('Pipelines Table Row', () => { - let component; - - beforeEach(() => { + const jsonFixtureName = 'pipelines/pipelines.json'; + const buildComponent = (pipeline) => { const PipelinesTableRowComponent = Vue.extend(tableRowComp); - - component = new PipelinesTableRowComponent({ + return new PipelinesTableRowComponent({ el: document.querySelector('.test-dom-element'), propsData: { pipeline, service: {}, }, }).$mount(); + }; + + let component; + let pipeline; + let pipelineWithoutAuthor; + let pipelineWithoutCommit; + + preloadFixtures(jsonFixtureName); + + beforeEach(() => { + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); + pipelineWithoutAuthor = pipelines.find(p => p.id === 2); + pipelineWithoutCommit = pipelines.find(p => p.id === 3); + }); + + afterEach(() => { + component.$destroy(); }); it('should render a table row', () => { + component = buildComponent(pipeline); expect(component.$el).toEqual('TR'); }); describe('status column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render a pipeline link', () => { expect( component.$el.querySelector('td.commit-link a').getAttribute('href'), @@ -36,6 +56,10 @@ describe('Pipelines Table Row', () => { }); describe('information column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render a pipeline link', () => { expect( component.$el.querySelector('td:nth-child(2) a').getAttribute('href'), @@ -52,10 +76,10 @@ describe('Pipelines Table Row', () => { it('should render user information', () => { expect( component.$el.querySelector('td:nth-child(2) a:nth-child(3)').getAttribute('href'), - ).toEqual(pipeline.user.web_url); + ).toEqual(pipeline.user.path); expect( - component.$el.querySelector('td:nth-child(2) img').getAttribute('title'), + component.$el.querySelector('td:nth-child(2) img').getAttribute('data-original-title'), ).toEqual(pipeline.user.name); }); }); @@ -63,13 +87,59 @@ describe('Pipelines Table Row', () => { describe('commit column', () => { it('should render link to commit', () => { - expect( - component.$el.querySelector('td:nth-child(3) .commit-id').getAttribute('href'), - ).toEqual(pipeline.commit.commit_path); + component = buildComponent(pipeline); + + const commitLink = component.$el.querySelector('.branch-commit .commit-sha'); + expect(commitLink.getAttribute('href')).toEqual(pipeline.commit.commit_path); + }); + + const findElements = () => { + const commitTitleElement = component.$el.querySelector('.branch-commit .commit-title'); + const commitAuthorElement = commitTitleElement.querySelector('a.avatar-image-container'); + + if (!commitAuthorElement) { + return { commitAuthorElement }; + } + + const commitAuthorLink = commitAuthorElement.getAttribute('href'); + const commitAuthorName = commitAuthorElement.querySelector('img.avatar').getAttribute('data-original-title'); + + return { commitAuthorElement, commitAuthorLink, commitAuthorName }; + }; + + it('renders nothing without commit', () => { + expect(pipelineWithoutCommit.commit).toBe(null); + component = buildComponent(pipelineWithoutCommit); + + const { commitAuthorElement } = findElements(); + + expect(commitAuthorElement).toBe(null); + }); + + it('renders commit author', () => { + component = buildComponent(pipeline); + const { commitAuthorLink, commitAuthorName } = findElements(); + + expect(commitAuthorLink).toEqual(pipeline.commit.author.path); + expect(commitAuthorName).toEqual(pipeline.commit.author.username); + }); + + it('renders commit with unregistered author', () => { + expect(pipelineWithoutAuthor.commit.author).toBe(null); + component = buildComponent(pipelineWithoutAuthor); + + const { commitAuthorLink, commitAuthorName } = findElements(); + + expect(commitAuthorLink).toEqual(`mailto:${pipelineWithoutAuthor.commit.author_email}`); + expect(commitAuthorName).toEqual(pipelineWithoutAuthor.commit.author_name); }); }); describe('stages column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render an icon for each stage', () => { expect( component.$el.querySelectorAll('td:nth-child(4) .js-builds-dropdown-button').length, @@ -78,6 +148,10 @@ describe('Pipelines Table Row', () => { }); describe('actions column', () => { + beforeEach(() => { + component = buildComponent(pipeline); + }); + it('should render the provided actions', () => { expect( component.$el.querySelectorAll('td:nth-child(6) ul li').length, diff --git a/spec/javascripts/vue_shared/components/pipelines_table_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_spec.js index 4d3ced944d7..6cc178b8f1d 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_spec.js +++ b/spec/javascripts/vue_shared/components/pipelines_table_spec.js @@ -1,13 +1,19 @@ import Vue from 'vue'; import pipelinesTableComp from '~/vue_shared/components/pipelines_table'; import '~/lib/utils/datetime_utility'; -import pipeline from '../../commit/pipelines/mock_data'; describe('Pipelines Table', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + + let pipeline; let PipelinesTableComponent; + preloadFixtures(jsonFixtureName); + beforeEach(() => { PipelinesTableComponent = Vue.extend(pipelinesTableComp); + const pipelines = getJSONFixture(jsonFixtureName).pipelines; + pipeline = pipelines.find(p => p.id === 1); }); describe('table', () => { diff --git a/spec/javascripts/vue_shared/components/table_pagination_spec.js b/spec/javascripts/vue_shared/components/table_pagination_spec.js index 96038718191..895e1c585b4 100644 --- a/spec/javascripts/vue_shared/components/table_pagination_spec.js +++ b/spec/javascripts/vue_shared/components/table_pagination_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import paginationComp from '~/vue_shared/components/table_pagination'; +import paginationComp from '~/vue_shared/components/table_pagination.vue'; import '~/lib/utils/common_utils'; describe('Pagination component', () => { diff --git a/spec/javascripts/vue_shared/components/time_ago_tooltip_spec.js b/spec/javascripts/vue_shared/components/time_ago_tooltip_spec.js new file mode 100644 index 00000000000..bf28019ef24 --- /dev/null +++ b/spec/javascripts/vue_shared/components/time_ago_tooltip_spec.js @@ -0,0 +1,68 @@ +import Vue from 'vue'; +import timeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import '~/lib/utils/datetime_utility'; + +describe('Time ago with tooltip component', () => { + let TimeagoTooltip; + let vm; + + beforeEach(() => { + TimeagoTooltip = Vue.extend(timeagoTooltip); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render timeago with a bootstrap tooltip', () => { + vm = new TimeagoTooltip({ + propsData: { + time: '2017-05-08T14:57:39.781Z', + }, + }).$mount(); + + expect(vm.$el.tagName).toEqual('TIME'); + expect(vm.$el.classList.contains('js-timeago')).toEqual(true); + expect( + vm.$el.getAttribute('data-original-title'), + ).toEqual(gl.utils.formatDate('2017-05-08T14:57:39.781Z')); + expect(vm.$el.getAttribute('data-placement')).toEqual('top'); + + const timeago = gl.utils.getTimeago(); + + expect(vm.$el.textContent.trim()).toEqual(timeago.format('2017-05-08T14:57:39.781Z')); + }); + + it('should render tooltip placed in bottom', () => { + vm = new TimeagoTooltip({ + propsData: { + time: '2017-05-08T14:57:39.781Z', + tooltipPlacement: 'bottom', + }, + }).$mount(); + + expect(vm.$el.getAttribute('data-placement')).toEqual('bottom'); + }); + + it('should render short format class', () => { + vm = new TimeagoTooltip({ + propsData: { + time: '2017-05-08T14:57:39.781Z', + shortFormat: true, + }, + }).$mount(); + + expect(vm.$el.classList.contains('js-short-timeago')).toEqual(true); + }); + + it('should render provided html class', () => { + vm = new TimeagoTooltip({ + propsData: { + time: '2017-05-08T14:57:39.781Z', + cssClass: 'foo', + }, + }).$mount(); + + expect(vm.$el.classList.contains('foo')).toEqual(true); + }); +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js new file mode 100644 index 00000000000..8daa7610274 --- /dev/null +++ b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js @@ -0,0 +1,54 @@ +import Vue from 'vue'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; + +const UserAvatarImageComponent = Vue.extend(UserAvatarImage); + +describe('User Avatar Image Component', function () { + describe('Initialization', function () { + beforeEach(function () { + this.propsData = { + size: 99, + imgSrc: 'myavatarurl.com', + imgAlt: 'mydisplayname', + cssClasses: 'myextraavatarclass', + tooltipText: 'tooltip text', + tooltipPlacement: 'bottom', + }; + + this.userAvatarImage = new UserAvatarImageComponent({ + propsData: this.propsData, + }).$mount(); + }); + + it('should return a defined Vue component', function () { + expect(this.userAvatarImage).toBeDefined(); + }); + + it('should have <img> as a child element', function () { + expect(this.userAvatarImage.$el.tagName).toBe('IMG'); + }); + + it('should properly compute tooltipContainer', function () { + expect(this.userAvatarImage.tooltipContainer).toBe('body'); + }); + + it('should properly render tooltipContainer', function () { + expect(this.userAvatarImage.$el.getAttribute('data-container')).toBe('body'); + }); + + it('should properly compute avatarSizeClass', function () { + expect(this.userAvatarImage.avatarSizeClass).toBe('s99'); + }); + + it('should properly render img css', function () { + const classList = this.userAvatarImage.$el.classList; + const containsAvatar = classList.contains('avatar'); + const containsSizeClass = classList.contains('s99'); + const containsCustomClass = classList.contains('myextraavatarclass'); + + expect(containsAvatar).toBe(true); + expect(containsSizeClass).toBe(true); + expect(containsCustomClass).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar_link_spec.js new file mode 100644 index 00000000000..52e450e9ba5 --- /dev/null +++ b/spec/javascripts/vue_shared/components/user_avatar_link_spec.js @@ -0,0 +1,50 @@ +import Vue from 'vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +describe('User Avatar Link Component', function () { + beforeEach(function () { + this.propsData = { + linkHref: 'myavatarurl.com', + imgSize: 99, + imgSrc: 'myavatarurl.com', + imgAlt: 'mydisplayname', + imgCssClasses: 'myextraavatarclass', + tooltipText: 'tooltip text', + tooltipPlacement: 'bottom', + }; + + const UserAvatarLinkComponent = Vue.extend(UserAvatarLink); + + this.userAvatarLink = new UserAvatarLinkComponent({ + propsData: this.propsData, + }).$mount(); + + this.userAvatarImage = this.userAvatarLink.$children[0]; + }); + + it('should return a defined Vue component', function () { + expect(this.userAvatarLink).toBeDefined(); + }); + + it('should have user-avatar-image registered as child component', function () { + expect(this.userAvatarLink.$options.components.userAvatarImage).toBeDefined(); + }); + + it('user-avatar-link should have user-avatar-image as child component', function () { + expect(this.userAvatarImage).toBeDefined(); + }); + + it('should render <a> as a child element', function () { + expect(this.userAvatarLink.$el.tagName).toBe('A'); + }); + + it('should have <img> as a child element', function () { + expect(this.userAvatarLink.$el.querySelector('img')).not.toBeNull(); + }); + + it('should return neccessary props as defined', function () { + _.each(this.propsData, (val, key) => { + expect(this.userAvatarLink[key]).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js b/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js new file mode 100644 index 00000000000..b8d639ffbec --- /dev/null +++ b/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import UserAvatarSvg from '~/vue_shared/components/user_avatar/user_avatar_svg.vue'; +import avatarSvg from 'icons/_icon_random.svg'; + +const UserAvatarSvgComponent = Vue.extend(UserAvatarSvg); + +describe('User Avatar Svg Component', function () { + describe('Initialization', function () { + beforeEach(function () { + this.propsData = { + size: 99, + svg: avatarSvg, + }; + + this.userAvatarSvg = new UserAvatarSvgComponent({ + propsData: this.propsData, + }).$mount(); + }); + + it('should return a defined Vue component', function () { + expect(this.userAvatarSvg).toBeDefined(); + }); + + it('should have <svg> as a child element', function () { + expect(this.userAvatarSvg.$el.tagName).toEqual('svg'); + expect(this.userAvatarSvg.$el.innerHTML).toContain('<path'); + }); + }); +}); diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js index 99515f2e5f2..4399c8b2025 100644 --- a/spec/javascripts/zen_mode_spec.js +++ b/spec/javascripts/zen_mode_spec.js @@ -3,7 +3,7 @@ /* global Mousetrap */ /* global ZenMode */ -require('~/zen_mode'); +import '~/zen_mode'; (function() { var enterZen, escapeKeydown, exitZen; diff --git a/spec/lib/banzai/filter/ascii_doc_post_processing_filter_spec.rb b/spec/lib/banzai/filter/ascii_doc_post_processing_filter_spec.rb new file mode 100644 index 00000000000..33b812ef425 --- /dev/null +++ b/spec/lib/banzai/filter/ascii_doc_post_processing_filter_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe Banzai::Filter::AsciiDocPostProcessingFilter, lib: true do + include FilterSpecHelper + + it "adds class for elements with data-math-style" do + result = filter('<pre data-math-style="inline">some code</pre><div data-math>and</div>').to_html + expect(result).to eq('<pre data-math-style="inline" class="code math js-render-math">some code</pre><div data-math>and</div>') + end + + it "keeps content when no data-math-style found" do + result = filter('<pre>some code</pre><div data-math>and</div>').to_html + expect(result).to eq('<pre>some code</pre><div data-math>and</div>') + end +end diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index fdbc65b5e00..fb7862f49a2 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -97,6 +97,22 @@ describe Banzai::Filter::SanitizationFilter, lib: true do expect(filter(act).to_html).to eq exp end + it 'allows `data-math-style` attribute on `code` and `pre` elements' do + html = <<-HTML + <pre class="code" data-math-style="inline">something</pre> + <code class="code" data-math-style="inline">something</code> + <div class="code" data-math-style="inline">something</div> + HTML + + output = <<-HTML + <pre data-math-style="inline">something</pre> + <code data-math-style="inline">something</code> + <div>something</div> + HTML + + expect(filter(html).to_html).to eq(output) + end + it 'removes `rel` attribute from `a` elements' do act = %q{<a href="#" rel="nofollow">Link</a>} exp = %q{<a href="#">Link</a>} diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb index 63b23dac7ed..edf3846b742 100644 --- a/spec/lib/banzai/filter/user_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb @@ -16,6 +16,11 @@ describe Banzai::Filter::UserReferenceFilter, lib: true do expect(reference_filter(act).to_html).to eq(exp) end + it 'ignores references with text before the @ sign' do + exp = act = "Hey foo#{reference}" + expect(reference_filter(act).to_html).to eq(exp) + end + %w(pre code a style).each do |elem| it "ignores valid references contained inside '#{elem}' element" do exp = act = "<#{elem}>Hey #{reference}</#{elem}>" diff --git a/spec/lib/banzai/reference_parser/user_parser_spec.rb b/spec/lib/banzai/reference_parser/user_parser_spec.rb index 4ec998efe53..592ed0d2b98 100644 --- a/spec/lib/banzai/reference_parser/user_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/user_parser_spec.rb @@ -42,6 +42,29 @@ describe Banzai::ReferenceParser::UserParser, lib: true do expect(subject.referenced_by([link])).to eq([user]) end + + context 'when RequestStore is active' do + let(:other_user) { create(:user) } + + before do + RequestStore.begin! + end + + after do + RequestStore.end! + RequestStore.clear! + end + + it 'does not return users from the first call in the second' do + link['data-user'] = user.id.to_s + + expect(subject.referenced_by([link])).to eq([user]) + + link['data-user'] = other_user.id.to_s + + expect(subject.referenced_by([link])).to eq([other_user]) + end + end end context 'when the link has a data-project attribute' do @@ -74,7 +97,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do end end - describe '#nodes_visible_to_use?' do + describe '#nodes_visible_to_user' do context 'when the link has a data-group attribute' do context 'using an existing group ID' do before do diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 53abc056602..2ca0773ad1d 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' module Ci - describe GitlabCiYamlProcessor, lib: true do + describe GitlabCiYamlProcessor, :lib do + subject { described_class.new(config, path) } let(:path) { 'path' } describe 'our current .gitlab-ci.yml' do @@ -82,6 +83,67 @@ module Ci end end + describe '#stage_seeds' do + context 'when no refs policy is specified' do + let(:config) do + YAML.dump(production: { stage: 'deploy', script: 'cap prod' }, + rspec: { stage: 'test', script: 'rspec' }, + spinach: { stage: 'test', script: 'spinach' }) + end + + let(:pipeline) { create(:ci_empty_pipeline) } + + it 'correctly fabricates a stage seeds object' do + seeds = subject.stage_seeds(pipeline) + + expect(seeds.size).to eq 2 + expect(seeds.first.stage[:name]).to eq 'test' + expect(seeds.second.stage[:name]).to eq 'deploy' + expect(seeds.first.builds.dig(0, :name)).to eq 'rspec' + expect(seeds.first.builds.dig(1, :name)).to eq 'spinach' + expect(seeds.second.builds.dig(0, :name)).to eq 'production' + end + end + + context 'when refs policy is specified' do + let(:config) do + YAML.dump(production: { stage: 'deploy', script: 'cap prod', only: ['master'] }, + spinach: { stage: 'test', script: 'spinach', only: ['tags'] }) + end + + let(:pipeline) do + create(:ci_empty_pipeline, ref: 'feature', tag: true) + end + + it 'returns stage seeds only assigned to master to master' do + seeds = subject.stage_seeds(pipeline) + + expect(seeds.size).to eq 1 + expect(seeds.first.stage[:name]).to eq 'test' + expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' + end + end + + context 'when source policy is specified' do + let(:config) do + YAML.dump(production: { stage: 'deploy', script: 'cap prod', only: ['triggers'] }, + spinach: { stage: 'test', script: 'spinach', only: ['schedules'] }) + end + + let(:pipeline) do + create(:ci_empty_pipeline, source: :schedule) + end + + it 'returns stage seeds only assigned to schedules' do + seeds = subject.stage_seeds(pipeline) + + expect(seeds.size).to eq 1 + expect(seeds.first.stage[:name]).to eq 'test' + expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' + end + end + end + describe "#builds_for_ref" do let(:type) { 'test' } @@ -176,26 +238,44 @@ module Ci expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(0) end - it "returns builds if only has a triggers keyword specified and a trigger is provided" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, only: ["triggers"] } - }) + it "returns builds if only has special keywords specified and source matches" do + possibilities = [{ keyword: 'pushes', source: 'push' }, + { keyword: 'web', source: 'web' }, + { keyword: 'triggers', source: 'trigger' }, + { keyword: 'schedules', source: 'schedule' }, + { keyword: 'api', source: 'api' }, + { keyword: 'external', source: 'external' }] - config_processor = GitlabCiYamlProcessor.new(config, path) + possibilities.each do |possibility| + config = YAML.dump({ + before_script: ["pwd"], + rspec: { script: "rspec", type: type, only: [possibility[:keyword]] } + }) - expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, true).size).to eq(1) + config_processor = GitlabCiYamlProcessor.new(config, path) + + expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(1) + end end - it "does not return builds if only has a triggers keyword specified and no trigger is provided" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, only: ["triggers"] } - }) + it "does not return builds if only has special keywords specified and source doesn't match" do + possibilities = [{ keyword: 'pushes', source: 'web' }, + { keyword: 'web', source: 'push' }, + { keyword: 'triggers', source: 'schedule' }, + { keyword: 'schedules', source: 'external' }, + { keyword: 'api', source: 'trigger' }, + { keyword: 'external', source: 'api' }] - config_processor = GitlabCiYamlProcessor.new(config, path) + possibilities.each do |possibility| + config = YAML.dump({ + before_script: ["pwd"], + rspec: { script: "rspec", type: type, only: [possibility[:keyword]] } + }) - expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(0) + config_processor = GitlabCiYamlProcessor.new(config, path) + + expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(0) + end end it "returns builds if only has current repository path" do @@ -225,7 +305,7 @@ module Ci before_script: ["pwd"], rspec: { script: "rspec", type: "test", only: %w(master deploy) }, staging: { script: "deploy", type: "deploy", only: %w(master deploy) }, - production: { script: "deploy", type: "deploy", only: ["master@path", "deploy"] }, + production: { script: "deploy", type: "deploy", only: ["master@path", "deploy"] } }) config_processor = GitlabCiYamlProcessor.new(config, 'fork') @@ -332,26 +412,44 @@ module Ci expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(1) end - it "does not return builds if except has a triggers keyword specified and a trigger is provided" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, except: ["triggers"] } - }) + it "does not return builds if except has special keywords specified and source matches" do + possibilities = [{ keyword: 'pushes', source: 'push' }, + { keyword: 'web', source: 'web' }, + { keyword: 'triggers', source: 'trigger' }, + { keyword: 'schedules', source: 'schedule' }, + { keyword: 'api', source: 'api' }, + { keyword: 'external', source: 'external' }] - config_processor = GitlabCiYamlProcessor.new(config, path) + possibilities.each do |possibility| + config = YAML.dump({ + before_script: ["pwd"], + rspec: { script: "rspec", type: type, except: [possibility[:keyword]] } + }) - expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, true).size).to eq(0) + config_processor = GitlabCiYamlProcessor.new(config, path) + + expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(0) + end end - it "returns builds if except has a triggers keyword specified and no trigger is provided" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, except: ["triggers"] } - }) + it "returns builds if except has special keywords specified and source doesn't match" do + possibilities = [{ keyword: 'pushes', source: 'web' }, + { keyword: 'web', source: 'push' }, + { keyword: 'triggers', source: 'schedule' }, + { keyword: 'schedules', source: 'external' }, + { keyword: 'api', source: 'trigger' }, + { keyword: 'external', source: 'api' }] - config_processor = GitlabCiYamlProcessor.new(config, path) + possibilities.each do |possibility| + config = YAML.dump({ + before_script: ["pwd"], + rspec: { script: "rspec", type: type, except: [possibility[:keyword]] } + }) - expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(1) + config_processor = GitlabCiYamlProcessor.new(config, path) + + expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, possibility[:source]).size).to eq(1) + end end it "does not return builds if except has current repository path" do @@ -381,7 +479,7 @@ module Ci before_script: ["pwd"], rspec: { script: "rspec", type: "test", except: ["master", "deploy", "test@fork"] }, staging: { script: "deploy", type: "deploy", except: ["master"] }, - production: { script: "deploy", type: "deploy", except: ["master@fork"] }, + production: { script: "deploy", type: "deploy", except: ["master@fork"] } }) config_processor = GitlabCiYamlProcessor.new(config, 'fork') @@ -716,7 +814,7 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "master").first[:options][:cache]).to eq( paths: ["logs/", "binaries/"], untracked: true, - key: 'key', + key: 'key' ) end @@ -734,7 +832,7 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "master").first[:options][:cache]).to eq( paths: ["logs/", "binaries/"], untracked: true, - key: 'key', + key: 'key' ) end @@ -743,7 +841,7 @@ module Ci cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' }, rspec: { script: "rspec", - cache: { paths: ["test/"], untracked: false, key: 'local' }, + cache: { paths: ["test/"], untracked: false, key: 'local' } } }) @@ -753,7 +851,7 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "master").first[:options][:cache]).to eq( paths: ["test/"], untracked: false, - key: 'local', + key: 'local' ) end end diff --git a/spec/lib/container_registry/blob_spec.rb b/spec/lib/container_registry/blob_spec.rb index f06e5fd54a2..ab010c6dfeb 100644 --- a/spec/lib/container_registry/blob_spec.rb +++ b/spec/lib/container_registry/blob_spec.rb @@ -98,7 +98,7 @@ describe ContainerRegistry::Blob do context 'for a valid address' do before do stub_request(:get, location). - with(headers: { 'Authorization' => nil }). + with { |request| !request.headers.include?('Authorization') }. to_return( status: 200, headers: { 'Content-Type' => 'application/json' }, diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb new file mode 100644 index 00000000000..ec03b533383 --- /dev/null +++ b/spec/lib/container_registry/client_spec.rb @@ -0,0 +1,39 @@ +# coding: utf-8 +require 'spec_helper' + +describe ContainerRegistry::Client do + let(:token) { '12345' } + let(:options) { { token: token } } + let(:client) { described_class.new("http://container-registry", options) } + + describe '#blob' do + it 'GET /v2/:name/blobs/:digest' do + stub_request(:get, "http://container-registry/v2/group/test/blobs/sha256:0123456789012345"). + with(headers: { + 'Accept' => 'application/octet-stream', + 'Authorization' => "bearer #{token}" + }). + to_return(status: 200, body: "Blob") + + expect(client.blob('group/test', 'sha256:0123456789012345')).to eq('Blob') + end + + it 'follows 307 redirect for GET /v2/:name/blobs/:digest' do + stub_request(:get, "http://container-registry/v2/group/test/blobs/sha256:0123456789012345"). + with(headers: { + 'Accept' => 'application/octet-stream', + 'Authorization' => "bearer #{token}" + }). + to_return(status: 307, body: "", headers: { Location: 'http://redirected' }) + # We should probably use hash_excluding here, but that requires an update to WebMock: + # https://github.com/bblimke/webmock/blob/master/lib/webmock/matchers/hash_excluding_matcher.rb + stub_request(:get, "http://redirected/"). + with { |request| !request.headers.include?('Authorization') }. + to_return(status: 200, body: "Successfully redirected") + + response = client.blob('group/test', 'sha256:0123456789012345') + + expect(response).to eq('Successfully redirected') + end + end +end diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb index 90628917943..7faa0f31b68 100644 --- a/spec/lib/expand_variables_spec.rb +++ b/spec/lib/expand_variables_spec.rb @@ -25,7 +25,7 @@ describe ExpandVariables do result: 'keyvalueresult', variables: [ { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, + { key: 'variable2', value: 'result' } ] }, { value: 'key${variable}${variable2}', result: 'keyvalueresult', @@ -37,7 +37,7 @@ describe ExpandVariables do result: 'keyresultvalue', variables: [ { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, + { key: 'variable2', value: 'result' } ] }, { value: 'key${variable2}${variable}', result: 'keyresultvalue', @@ -49,7 +49,7 @@ describe ExpandVariables do result: 'review/feature/add-review-apps', variables: [ { key: 'CI_COMMIT_REF_NAME', value: 'feature/add-review-apps' } - ] }, + ] } ] tests.each do |test| diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb new file mode 100644 index 00000000000..1d92a5cb33f --- /dev/null +++ b/spec/lib/feature_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Feature, lib: true do + describe '.get' do + let(:feature) { double(:feature) } + let(:key) { 'my_feature' } + + it 'returns the Flipper feature' do + expect_any_instance_of(Flipper::DSL).to receive(:feature).with(key). + and_return(feature) + + expect(described_class.get(key)).to be(feature) + end + end + + describe '.all' do + let(:features) { Set.new } + + it 'returns the Flipper features as an array' do + expect_any_instance_of(Flipper::DSL).to receive(:features). + and_return(features) + + expect(described_class.all).to eq(features.to_a) + end + end +end diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb index 2c7ebb15fd7..b245e3e35c3 100644 --- a/spec/lib/gitlab/asciidoc_spec.rb +++ b/spec/lib/gitlab/asciidoc_spec.rb @@ -70,6 +70,34 @@ module Gitlab expect(output).to include('rel="nofollow noreferrer noopener"') end end +<<<<<<< HEAD +======= + + context 'LaTex code' do + it 'adds class js-render-math to the output' do + input = <<~MD + :stem: latexmath + + [stem] + ++++ + \sqrt{4} = 2 + ++++ + + another part + + [latexmath] + ++++ + \beta_x \gamma + ++++ + + stem:[2+2] is 4 + MD + + expect(render(input, context)).to include('<pre data-math-style="display" class="code math js-render-math"><code>eta_x gamma</code></pre>') + expect(render(input, context)).to include('<p><code data-math-style="inline" class="code math js-render-math">2+2</code> is 4</p>') + end + end +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end def render(*args) diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index d4a43192d03..d6006eab0c9 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -17,7 +17,11 @@ describe Gitlab::Auth, lib: true do end it 'OPTIONAL_SCOPES contains all non-default scopes' do - expect(subject::OPTIONAL_SCOPES).to eq [:read_user, :openid] + expect(subject::OPTIONAL_SCOPES).to eq %i[read_user read_registry openid] + end + + it 'REGISTRY_SCOPES contains all registry related scopes' do + expect(subject::REGISTRY_SCOPES).to eq %i[read_registry] end end @@ -143,6 +147,13 @@ describe Gitlab::Auth, lib: true do expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, full_authentication_abilities)) end + it 'succeeds for personal access tokens with the `read_registry` scope' do + personal_access_token = create(:personal_access_token, scopes: ['read_registry']) + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') + expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, [:read_container_image])) + end + it 'succeeds if it is an impersonation token' do impersonation_token = create(:personal_access_token, :impersonation, scopes: ['api']) @@ -150,18 +161,11 @@ describe Gitlab::Auth, lib: true do expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(impersonation_token.user, nil, :personal_token, full_authentication_abilities)) end - it 'fails for personal access tokens with other scopes' do + it 'limits abilities based on scope' do personal_access_token = create(:personal_access_token, scopes: ['read_user']) - expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '') - expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) - end - - it 'fails for impersonation token with other scopes' do - impersonation_token = create(:personal_access_token, scopes: ['read_user']) - - expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '') - expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') + expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, [])) end it 'fails if password is nil' do @@ -175,7 +179,7 @@ describe Gitlab::Auth, lib: true do user = create( :user, username: 'normal_user', - password: 'my-secret', + password: 'my-secret' ) expect(gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip')) @@ -186,7 +190,7 @@ describe Gitlab::Auth, lib: true do user = create( :user, username: 'oauth2', - password: 'my-secret', + password: 'my-secret' ) expect(gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip')) diff --git a/spec/lib/gitlab/backup/manager_spec.rb b/spec/lib/gitlab/backup/manager_spec.rb index 2dd428bf20b..1c3d2547fec 100644 --- a/spec/lib/gitlab/backup/manager_spec.rb +++ b/spec/lib/gitlab/backup/manager_spec.rb @@ -158,8 +158,8 @@ describe Backup::Manager, lib: true do before do allow(Dir).to receive(:glob).and_return( [ - '1451606400_2016_01_01_gitlab_backup.tar', - '1451520000_2015_12_31_gitlab_backup.tar', + '1451606400_2016_01_01_1.2.3_gitlab_backup.tar', + '1451520000_2015_12_31_gitlab_backup.tar' ] ) end diff --git a/spec/lib/gitlab/backup/repository_spec.rb b/spec/lib/gitlab/backup/repository_spec.rb new file mode 100644 index 00000000000..51c1e9d657b --- /dev/null +++ b/spec/lib/gitlab/backup/repository_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +describe Backup::Repository, lib: true do + let(:progress) { StringIO.new } + let!(:project) { create(:empty_project) } + + before do + allow(progress).to receive(:puts) + allow(progress).to receive(:print) + + allow_any_instance_of(String).to receive(:color) do |string, _color| + string + end + + allow_any_instance_of(described_class).to receive(:progress).and_return(progress) + end + + describe '#dump' do + describe 'repo failure' do + before do + allow_any_instance_of(Repository).to receive(:empty_repo?).and_raise(Rugged::OdbError) + allow(Gitlab::Popen).to receive(:popen).and_return(['normal output', 0]) + end + + it 'does not raise error' do + expect { described_class.new.dump }.not_to raise_error + end + + it 'shows the appropriate error' do + described_class.new.dump + + expect(progress).to have_received(:puts).with("Ignoring repository error and continuing backing up project: #{project.full_path} - Rugged::OdbError") + end + end + + describe 'command failure' do + before do + allow_any_instance_of(Repository).to receive(:empty_repo?).and_return(false) + allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1]) + end + + it 'shows the appropriate error' do + described_class.new.dump + + expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error") + end + end + end + + describe '#restore' do + describe 'command failure' do + before do + allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1]) + end + + it 'shows the appropriate error' do + described_class.new.restore + + expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error") + end + end + end +end diff --git a/spec/lib/gitlab/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb index eb4f06b371c..13e6953147b 100644 --- a/spec/lib/gitlab/chat_commands/command_spec.rb +++ b/spec/lib/gitlab/chat_commands/command_spec.rb @@ -58,9 +58,12 @@ describe Gitlab::ChatCommands::Command, service: true do end end - context 'and user does have deployment permission' do + context 'and user has deployment permission' do before do - build.project.add_master(user) + build.project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'returns action' do diff --git a/spec/lib/gitlab/chat_commands/deploy_spec.rb b/spec/lib/gitlab/chat_commands/deploy_spec.rb index b33389d959e..46dbdeae37c 100644 --- a/spec/lib/gitlab/chat_commands/deploy_spec.rb +++ b/spec/lib/gitlab/chat_commands/deploy_spec.rb @@ -7,7 +7,12 @@ describe Gitlab::ChatCommands::Deploy, service: true do let(:regex_match) { described_class.match('deploy staging to production') } before do - project.add_master(user) + # Make it possible to trigger protected manual actions for developers. + # + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end subject do diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb index 959ae02c222..c0c309d8179 100644 --- a/spec/lib/gitlab/checks/change_access_spec.rb +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -23,29 +23,27 @@ describe Gitlab::Checks::ChangeAccess, lib: true do before { project.add_developer(user) } context 'without failed checks' do - it "doesn't return any error" do - expect(subject.status).to be(true) + it "doesn't raise an error" do + expect { subject }.not_to raise_error end end context 'when the user is not allowed to push code' do - it 'returns an error' do + it 'raises 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.') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.') end end context 'tags check' do let(:ref) { 'refs/tags/v1.0.0' } - it 'returns an error if the user is not allowed to update tags' do + it 'raises an error if the user is not allowed to update tags' do allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false) - expect(subject.status).to be(false) - expect(subject.message).to eq('You are not allowed to change existing tags on this project.') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to change existing tags on this project.') end context 'with protected tag' do @@ -59,8 +57,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do let(:newrev) { '0000000000000000000000000000000000000000' } it 'is prevented' do - expect(subject.status).to be(false) - expect(subject.message).to include('cannot be deleted') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be deleted/) end end @@ -69,8 +66,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } it 'is prevented' do - expect(subject.status).to be(false) - expect(subject.message).to include('cannot be updated') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be updated/) end end end @@ -81,55 +77,85 @@ describe Gitlab::Checks::ChangeAccess, lib: true do let(:ref) { 'refs/tags/v9.1.0' } it 'prevents creation below access level' do - expect(subject.status).to be(false) - expect(subject.message).to include('allowed to create this tag as it is protected') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /allowed to create this tag as it is protected/) end context 'when user has access' do let!(:protected_tag) { create(:protected_tag, :developers_can_create, project: project, name: 'v*') } it 'allows tag creation' do - expect(subject.status).to be(true) + expect { subject }.not_to raise_error end end end end end - context 'protected branches check' do - before do - allow(ProtectedBranch).to receive(:protected?).with(project, 'master').and_return(true) + context 'branches check' do + context 'trying to delete the default branch' do + let(:newrev) { '0000000000000000000000000000000000000000' } + let(:ref) { 'refs/heads/master' } + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'The default branch of a project cannot be deleted.') + end 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) + context 'protected branches check' do + before do + allow(ProtectedBranch).to receive(:protected?).with(project, 'master').and_return(true) + allow(ProtectedBranch).to receive(:protected?).with(project, 'feature').and_return(true) + end - 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 'raises 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) - 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 }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to force push code to a protected branch on this project.') + end - 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 'raises 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) - 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 }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to merge code into protected branches on this project.') + end - 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 + it 'raises 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) - context 'branch deletion' do - let(:newrev) { '0000000000000000000000000000000000000000' } + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to protected branches on this project.') + end - it 'returns an error if the user is not allowed to delete protected branches' do - expect(subject.status).to be(false) - expect(subject.message).to eq('You are not allowed to delete protected branches from this project.') + context 'branch deletion' do + let(:newrev) { '0000000000000000000000000000000000000000' } + let(:ref) { 'refs/heads/feature' } + + context 'if the user is not allowed to delete protected branches' do + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.') + end + end + + context 'if the user is allowed to delete protected branches' do + before do + project.add_master(user) + end + + context 'through the web interface' do + let(:protocol) { 'web' } + + it 'allows branch deletion' do + expect { subject }.not_to raise_error + end + end + + context 'over SSH or HTTP' do + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You can only delete protected branches using the web interface.') + end + end + end end end end diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb index 684d01e9056..23270ad5053 100644 --- a/spec/lib/gitlab/ci/config/entry/global_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb @@ -113,7 +113,7 @@ describe Gitlab::Ci::Config::Entry::Global do describe '#variables_value' do it 'returns variables' do - expect(global.variables_value).to eq(VAR: 'value') + expect(global.variables_value).to eq('VAR' => 'value') end end @@ -154,7 +154,7 @@ describe Gitlab::Ci::Config::Entry::Global do services: ['postgres:9.1', 'mysql:5.5'], stage: 'test', cache: { key: 'k', untracked: true, paths: ['public/'] }, - variables: { VAR: 'value' }, + variables: { 'VAR' => 'value' }, ignore: false, after_script: ['make clean'] }, spinach: { name: :spinach, @@ -167,7 +167,7 @@ describe Gitlab::Ci::Config::Entry::Global do cache: { key: 'k', untracked: true, paths: ['public/'] }, variables: {}, ignore: false, - after_script: ['make clean'] }, + after_script: ['make clean'] } ) end end diff --git a/spec/lib/gitlab/ci/config/entry/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/variables_spec.rb index f15f02f403e..84bfef9e8ad 100644 --- a/spec/lib/gitlab/ci/config/entry/variables_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/variables_spec.rb @@ -13,6 +13,14 @@ describe Gitlab::Ci::Config::Entry::Variables do it 'returns hash with key value strings' do expect(entry.value).to eq config end + + context 'with numeric keys and values in the config' do + let(:config) { { 10 => 20 } } + + it 'converts numeric key and numeric value into strings' do + expect(entry.value).to eq('10' => '20') + end + end end describe '#errors' do diff --git a/spec/lib/gitlab/ci/stage/seed_spec.rb b/spec/lib/gitlab/ci/stage/seed_spec.rb new file mode 100644 index 00000000000..d7e91a5a62c --- /dev/null +++ b/spec/lib/gitlab/ci/stage/seed_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe Gitlab::Ci::Stage::Seed do + let(:pipeline) { create(:ci_empty_pipeline) } + + let(:builds) do + [{ name: 'rspec' }, { name: 'spinach' }] + end + + subject do + described_class.new(pipeline, 'test', builds) + end + + describe '#stage' do + it 'returns hash attributes of a stage' do + expect(subject.stage).to be_a Hash + expect(subject.stage).to include(:name, :project) + end + end + + describe '#builds' do + it 'returns hash attributes of all builds' do + expect(subject.builds.size).to eq 2 + expect(subject.builds).to all(include(ref: 'master')) + expect(subject.builds).to all(include(tag: false)) + expect(subject.builds).to all(include(project: pipeline.project)) + expect(subject.builds) + .to all(include(trigger_request: pipeline.trigger_requests.first)) + end + end + + describe '#user=' do + let(:user) { build(:user) } + + it 'assignes relevant pipeline attributes' do + subject.user = user + + expect(subject.builds).to all(include(user: user)) + end + end + + describe '#create!' do + it 'creates all stages and builds' do + subject.create! + + expect(pipeline.reload.stages.count).to eq 1 + expect(pipeline.reload.builds.count).to eq 2 + expect(pipeline.builds).to all(satisfy { |job| job.stage_id.present? }) + expect(pipeline.builds).to all(satisfy { |job| job.pipeline.present? }) + expect(pipeline.builds).to all(satisfy { |job| job.project.present? }) + expect(pipeline.stages) + .to all(satisfy { |stage| stage.pipeline.present? }) + expect(pipeline.stages) + .to all(satisfy { |stage| stage.project.present? }) + end + end +end diff --git a/spec/lib/gitlab/ci/status/build/common_spec.rb b/spec/lib/gitlab/ci/status/build/common_spec.rb index 40b96b1807b..72bd7c4eb93 100644 --- a/spec/lib/gitlab/ci/status/build/common_spec.rb +++ b/spec/lib/gitlab/ci/status/build/common_spec.rb @@ -31,7 +31,7 @@ describe Gitlab::Ci::Status::Build::Common do describe '#details_path' do it 'links to the build details page' do - expect(subject.details_path).to include "builds/#{build.id}" + expect(subject.details_path).to include "jobs/#{build.id}" end end end diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb index 185bb9098da..3f30b2c38f2 100644 --- a/spec/lib/gitlab/ci/status/build/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb @@ -224,7 +224,10 @@ describe Gitlab::Ci::Status::Build::Factory do context 'when user has ability to play action' do before do - build.project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'fabricates status that has action' do diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb index f5d0f977768..0e15a5f3c6b 100644 --- a/spec/lib/gitlab/ci/status/build/play_spec.rb +++ b/spec/lib/gitlab/ci/status/build/play_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::Ci::Status::Build::Play do let(:user) { create(:user) } + let(:project) { build.project } let(:build) { create(:ci_build, :manual) } let(:status) { Gitlab::Ci::Status::Core.new(build, user) } @@ -15,8 +16,13 @@ describe Gitlab::Ci::Status::Build::Play do describe '#has_action?' do context 'when user is allowed to update build' do - context 'when user can push to branch' do - before { build.project.add_master(user) } + context 'when user is allowed to trigger protected action' do + before do + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) + end it { is_expected.to have_action } end diff --git a/spec/lib/gitlab/ci/trace/stream_spec.rb b/spec/lib/gitlab/ci/trace/stream_spec.rb index 40ac5a3ed37..bbb3f9912a3 100644 --- a/spec/lib/gitlab/ci/trace/stream_spec.rb +++ b/spec/lib/gitlab/ci/trace/stream_spec.rb @@ -240,9 +240,50 @@ describe Gitlab::Ci::Trace::Stream do end context 'multiple results in content & regex' do - let(:data) { ' (98.39%) covered. (98.29%) covered' } + let(:data) do + <<~HEREDOC + (98.39%) covered + (98.29%) covered + HEREDOC + end + + let(:regex) { '\(\d+.\d+\%\) covered' } + + it 'returns the last matched coverage' do + is_expected.to eq("98.29") + end + end + + context 'when BUFFER_SIZE is smaller than stream.size' do + let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } + let(:regex) { '\(\d+.\d+\%\) covered' } + + before do + stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5) + end + + it { is_expected.to eq("98.29") } + end + + context 'when regex is multi-byte char' do + let(:data) { '95.0 ゴッドファット\n' } + let(:regex) { '\d+\.\d+ ゴッドファット' } + + before do + stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5) + end + + it { is_expected.to eq('95.0') } + end + + context 'when BUFFER_SIZE is equal to stream.size' do + let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } let(:regex) { '\(\d+.\d+\%\) covered' } + before do + stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', data.length) + end + it { is_expected.to eq("98.29") } end diff --git a/spec/lib/gitlab/ci_access_spec.rb b/spec/lib/gitlab/ci_access_spec.rb new file mode 100644 index 00000000000..eaf8f1d0f1c --- /dev/null +++ b/spec/lib/gitlab/ci_access_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe Gitlab::CiAccess, lib: true do + let(:access) { Gitlab::CiAccess.new } + + describe '#can_do_action?' do + context 'when action is :build_download_code' do + it { expect(access.can_do_action?(:build_download_code)).to be_truthy } + end + + context 'when action is not :build_download_code' do + it { expect(access.can_do_action?(:download_code)).to be_falsey } + end + end +end diff --git a/spec/lib/gitlab/contributions_calendar_spec.rb b/spec/lib/gitlab/contributions_calendar_spec.rb index e18a219ef36..79632e2b6a3 100644 --- a/spec/lib/gitlab/contributions_calendar_spec.rb +++ b/spec/lib/gitlab/contributions_calendar_spec.rb @@ -47,7 +47,7 @@ describe Gitlab::ContributionsCalendar do action: Event::CREATED, target: @targets[project], author: contributor, - created_at: day, + created_at: day ) end diff --git a/spec/lib/gitlab/current_settings_spec.rb b/spec/lib/gitlab/current_settings_spec.rb index c796c98ec9f..fda39d78610 100644 --- a/spec/lib/gitlab/current_settings_spec.rb +++ b/spec/lib/gitlab/current_settings_spec.rb @@ -14,20 +14,20 @@ describe Gitlab::CurrentSettings do end it 'attempts to use cached values first' do - expect(ApplicationSetting).to receive(:current) - expect(ApplicationSetting).not_to receive(:last) + expect(ApplicationSetting).to receive(:cached) expect(current_application_settings).to be_a(ApplicationSetting) end it 'falls back to DB if Redis returns an empty value' do + expect(ApplicationSetting).to receive(:cached).and_return(nil) expect(ApplicationSetting).to receive(:last).and_call_original expect(current_application_settings).to be_a(ApplicationSetting) end it 'falls back to DB if Redis fails' do - expect(ApplicationSetting).to receive(:current).and_raise(::Redis::BaseError) + expect(ApplicationSetting).to receive(:cached).and_raise(::Redis::BaseError) expect(ApplicationSetting).to receive(:last).and_call_original expect(current_application_settings).to be_a(ApplicationSetting) @@ -37,6 +37,7 @@ describe Gitlab::CurrentSettings do context 'with DB unavailable' do before do allow_any_instance_of(described_class).to receive(:connect_to_db?).and_return(false) + allow_any_instance_of(described_class).to receive(:retrieve_settings_from_database_cache?).and_return(nil) end it 'returns an in-memory ApplicationSetting object' do diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb index 9d2ba481919..a1b3fe8509e 100644 --- a/spec/lib/gitlab/cycle_analytics/events_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb @@ -11,8 +11,6 @@ describe 'cycle analytics events' do end before do - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([context]) - setup(context) end @@ -128,7 +126,8 @@ describe 'cycle analytics events' do create(:ci_pipeline, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, - project: context.project) + project: context.project, + head_pipeline_of: merge_request) end before do @@ -224,7 +223,8 @@ describe 'cycle analytics events' do create(:ci_pipeline, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, - project: context.project) + project: context.project, + head_pipeline_of: merge_request) end before do @@ -332,7 +332,7 @@ describe 'cycle analytics events' do def setup(context) milestone = create(:milestone, project: project) context.update(milestone: milestone) - mr = create_merge_request_closing_issue(context) + mr = create_merge_request_closing_issue(context, commit_message: "References #{context.to_reference}") ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash) end diff --git a/spec/lib/gitlab/data_builder/push_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb index dbcfb9b7400..e59cba35b2f 100644 --- a/spec/lib/gitlab/data_builder/push_spec.rb +++ b/spec/lib/gitlab/data_builder/push_spec.rb @@ -35,6 +35,7 @@ describe Gitlab::DataBuilder::Push, lib: true do it { expect(data[:ref]).to eq('refs/tags/v1.1.0') } it { expect(data[:user_id]).to eq(user.id) } it { expect(data[:user_name]).to eq(user.name) } + it { expect(data[:user_username]).to eq(user.username) } it { expect(data[:user_email]).to eq(user.email) } it { expect(data[:user_avatar]).to eq(user.avatar_url) } it { expect(data[:project_id]).to eq(project.id) } diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index dfa3ae9142e..3fdafd867da 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -66,16 +66,23 @@ describe Gitlab::Database::MigrationHelpers, lib: true do context 'using PostgreSQL' do before do - allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + allow(model).to receive(:supports_drop_index_concurrently?).and_return(true) allow(model).to receive(:disable_statement_timeout) end - it 'removes the index concurrently' do + it 'removes the index concurrently by column name' do expect(model).to receive(:remove_index). with(:users, { algorithm: :concurrently, column: :foo }) model.remove_concurrent_index(:users, :foo) end + + it 'removes the index concurrently by index name' do + expect(model).to receive(:remove_index). + with(:users, { algorithm: :concurrently, name: "index_x_by_y" }) + + model.remove_concurrent_index_by_name(:users, "index_x_by_y") + end end context 'using MySQL' do @@ -247,6 +254,14 @@ describe Gitlab::Database::MigrationHelpers, lib: true do expect(Project.where(archived: true).count).to eq(1) end end + + context 'when the value is Arel.sql (Arel::Nodes::SqlLiteral)' do + it 'updates the value as a SQL expression' do + model.update_column_in_batches(:projects, :star_count, Arel.sql('1+1')) + + expect(Project.sum(:star_count)).to eq(2 * Project.count) + end + end end describe '#add_column_with_default' do diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb index c56fded7516..099e2ecd682 100644 --- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb @@ -18,8 +18,8 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do let(:subject) { described_class.new(['parent/the-Path'], migration) } it 'includes the namespace' do - parent = create(:namespace, path: 'parent') - child = create(:namespace, path: 'the-path', parent: parent) + parent = create(:group, path: 'parent') + child = create(:group, path: 'the-path', parent: parent) found_ids = subject.namespaces_for_paths(type: :child). map(&:id) @@ -30,17 +30,33 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do context 'for child namespaces' do it 'only returns child namespaces with the correct path' do - _root_namespace = create(:namespace, path: 'THE-path') - _other_path = create(:namespace, + _root_namespace = create(:group, path: 'THE-path') + _other_path = create(:group, path: 'other', - parent: create(:namespace)) - namespace = create(:namespace, + parent: create(:group)) + namespace = create(:group, path: 'the-path', - parent: create(:namespace)) + parent: create(:group)) + + found_ids = subject.namespaces_for_paths(type: :child). + map(&:id) + + expect(found_ids).to contain_exactly(namespace.id) + end + + it 'has no namespaces that look the same' do + _root_namespace = create(:group, path: 'THE-path') + _similar_path = create(:group, + path: 'not-really-the-path', + parent: create(:group)) + namespace = create(:group, + path: 'the-path', + parent: create(:group)) found_ids = subject.namespaces_for_paths(type: :child). map(&:id) +<<<<<<< HEAD expect(found_ids).to contain_exactly(namespace.id) end @@ -56,21 +72,37 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do found_ids = subject.namespaces_for_paths(type: :child). map(&:id) +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(found_ids).to contain_exactly(namespace.id) end end context 'for top levelnamespaces' do it 'only returns child namespaces with the correct path' do - root_namespace = create(:namespace, path: 'the-path') - _other_path = create(:namespace, path: 'other') - _child_namespace = create(:namespace, + root_namespace = create(:group, path: 'the-path') + _other_path = create(:group, path: 'other') + _child_namespace = create(:group, path: 'the-path', - parent: create(:namespace)) + parent: create(:group)) + + found_ids = subject.namespaces_for_paths(type: :top_level). + map(&:id) + + expect(found_ids).to contain_exactly(root_namespace.id) + end + + it 'has no namespaces that just look the same' do + root_namespace = create(:group, path: 'the-path') + _similar_path = create(:group, path: 'not-really-the-path') + _child_namespace = create(:group, + path: 'the-path', + parent: create(:group)) found_ids = subject.namespaces_for_paths(type: :top_level). map(&:id) +<<<<<<< HEAD expect(found_ids).to contain_exactly(root_namespace.id) end @@ -84,6 +116,8 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do found_ids = subject.namespaces_for_paths(type: :top_level). map(&:id) +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(found_ids).to contain_exactly(root_namespace.id) end end @@ -124,10 +158,10 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do describe "#child_ids_for_parent" do it "collects child ids for all levels" do - parent = create(:namespace) - first_child = create(:namespace, parent: parent) - second_child = create(:namespace, parent: parent) - third_child = create(:namespace, parent: second_child) + parent = create(:group) + first_child = create(:group, parent: parent) + second_child = create(:group, parent: parent) + third_child = create(:group, parent: second_child) all_ids = [parent.id, first_child.id, second_child.id, third_child.id] collected_ids = subject.child_ids_for_parent(parent, ids: [parent.id]) @@ -205,9 +239,9 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do end describe '#rename_namespaces' do - let!(:top_level_namespace) { create(:namespace, path: 'the-path') } + let!(:top_level_namespace) { create(:group, path: 'the-path') } let!(:child_namespace) do - create(:namespace, path: 'the-path', parent: create(:namespace)) + create(:group, path: 'the-path', parent: create(:group)) end it 'renames top level namespaces the namespace' do diff --git a/spec/lib/gitlab/dependency_linker/cartfile_linker_spec.rb b/spec/lib/gitlab/dependency_linker/cartfile_linker_spec.rb new file mode 100644 index 00000000000..df77f4037af --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/cartfile_linker_spec.rb @@ -0,0 +1,74 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::CartfileLinker, lib: true do + describe '.support?' do + it 'supports Cartfile' do + expect(described_class.support?('Cartfile')).to be_truthy + end + + it 'supports Cartfile.private' do + expect(described_class.support?('Cartfile.private')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('test.Cartfile')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "Cartfile" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + # Require version 2.3.1 or later + github "ReactiveCocoa/ReactiveCocoa" >= 2.3.1 + + # Require version 1.x + github "Mantle/Mantle" ~> 1.0 # (1.0 or later, but less than 2.0) + + # Require exactly version 0.4.1 + github "jspahrsummers/libextobjc" == 0.4.1 + + # Use the latest version + github "jspahrsummers/xcconfigs" + + # Use the branch + github "jspahrsummers/xcconfigs" "branch" + + # Use a project from GitHub Enterprise + github "https://enterprise.local/ghe/desktop/git-error-translations" + + # Use a project from any arbitrary server, on the "development" branch + git "https://enterprise.local/desktop/git-error-translations2.git" "development" + + # Use a local project + git "file:///directory/to/project" "branch" + + # A binary only framework + binary "https://my.domain.com/release/MyFramework.json" ~> 2.3 + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links dependencies' do + expect(subject).to include(link('ReactiveCocoa/ReactiveCocoa', 'https://github.com/ReactiveCocoa/ReactiveCocoa')) + expect(subject).to include(link('Mantle/Mantle', 'https://github.com/Mantle/Mantle')) + expect(subject).to include(link('jspahrsummers/libextobjc', 'https://github.com/jspahrsummers/libextobjc')) + expect(subject).to include(link('jspahrsummers/xcconfigs', 'https://github.com/jspahrsummers/xcconfigs')) + end + + it 'links Git repos' do + expect(subject).to include(link('https://enterprise.local/ghe/desktop/git-error-translations', 'https://enterprise.local/ghe/desktop/git-error-translations')) + expect(subject).to include(link('https://enterprise.local/desktop/git-error-translations2.git', 'https://enterprise.local/desktop/git-error-translations2.git')) + end + + it 'links binary-only frameworks' do + expect(subject).to include(link('https://my.domain.com/release/MyFramework.json', 'https://my.domain.com/release/MyFramework.json')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/composer_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/composer_json_linker_spec.rb new file mode 100644 index 00000000000..d7a926e800f --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/composer_json_linker_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::ComposerJsonLinker, lib: true do + describe '.support?' do + it 'supports composer.json' do + expect(described_class.support?('composer.json')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('composer.json.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "composer.json" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + { + "name": "laravel/laravel", + "homepage": "https://laravel.com/", + "description": "The Laravel Framework.", + "keywords": ["framework", "laravel"], + "license": "MIT", + "type": "project", + "repositories": [ + { + "type": "git", + "url": "https://github.com/laravel/laravel.git" + } + ], + "require": { + "php": ">=5.5.9", + "laravel/framework": "5.2.*" + }, + "require-dev": { + "fzaninotto/faker": "~1.4", + "mockery/mockery": "0.9.*", + "phpunit/phpunit": "~4.0", + "symfony/css-selector": "2.8.*|3.0.*", + "symfony/dom-crawler": "2.8.*|3.0.*" + } + } + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the module name' do + expect(subject).to include(link('laravel/laravel', 'https://packagist.org/packages/laravel/laravel')) + end + + it 'links the homepage' do + expect(subject).to include(link('https://laravel.com/', 'https://laravel.com/')) + end + + it 'links the repository URL' do + expect(subject).to include(link('https://github.com/laravel/laravel.git', 'https://github.com/laravel/laravel.git')) + end + + it 'links the license' do + expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/')) + end + + it 'links dependencies' do + expect(subject).to include(link('laravel/framework', 'https://packagist.org/packages/laravel/framework')) + expect(subject).to include(link('fzaninotto/faker', 'https://packagist.org/packages/fzaninotto/faker')) + expect(subject).to include(link('mockery/mockery', 'https://packagist.org/packages/mockery/mockery')) + expect(subject).to include(link('phpunit/phpunit', 'https://packagist.org/packages/phpunit/phpunit')) + expect(subject).to include(link('symfony/css-selector', 'https://packagist.org/packages/symfony/css-selector')) + expect(subject).to include(link('symfony/dom-crawler', 'https://packagist.org/packages/symfony/dom-crawler')) + end + + it 'does not link core dependencies' do + expect(subject).not_to include(link('php', 'https://packagist.org/packages/php')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb b/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb new file mode 100644 index 00000000000..3f8335f03ea --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::GemfileLinker, lib: true do + describe '.support?' do + it 'supports Gemfile' do + expect(described_class.support?('Gemfile')).to be_truthy + end + + it 'supports gems.rb' do + expect(described_class.support?('gems.rb')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('Gemfile.lock')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { 'Gemfile' } + + let(:file_content) do + <<-CONTENT.strip_heredoc + source 'https://rubygems.org' + + gem "rails", '4.2.6', github: "rails/rails" + gem 'rails-deprecated_sanitizer', '~> 1.0.3' + gem 'responders', '~> 2.0', :github => 'rails/responders' + gem 'sprockets', '~> 3.6.0', git: 'https://gitlab.example.com/gems/sprockets' + gem 'default_value_for', '~> 3.0.0' + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links sources' do + expect(subject).to include(link('https://rubygems.org', 'https://rubygems.org')) + end + + it 'links dependencies' do + expect(subject).to include(link('rails', 'https://rubygems.org/gems/rails')) + expect(subject).to include(link('rails-deprecated_sanitizer', 'https://rubygems.org/gems/rails-deprecated_sanitizer')) + expect(subject).to include(link('responders', 'https://rubygems.org/gems/responders')) + expect(subject).to include(link('sprockets', 'https://rubygems.org/gems/sprockets')) + expect(subject).to include(link('default_value_for', 'https://rubygems.org/gems/default_value_for')) + end + + it 'links GitHub repos' do + expect(subject).to include(link('rails/rails', 'https://github.com/rails/rails')) + expect(subject).to include(link('rails/responders', 'https://github.com/rails/responders')) + end + + it 'links Git repos' do + expect(subject).to include(link('https://gitlab.example.com/gems/sprockets', 'https://gitlab.example.com/gems/sprockets')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/gemspec_linker_spec.rb b/spec/lib/gitlab/dependency_linker/gemspec_linker_spec.rb new file mode 100644 index 00000000000..d4a71403939 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/gemspec_linker_spec.rb @@ -0,0 +1,66 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::GemspecLinker, lib: true do + describe '.support?' do + it 'supports *.gemspec' do + expect(described_class.support?('gitlab_git.gemspec')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('.gemspec.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "gitlab_git.gemspec" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + Gem::Specification.new do |s| + s.name = 'gitlab_git' + s.version = `cat VERSION` + s.date = Time.now.strftime('%Y-%m-%d') + s.summary = "Gitlab::Git library" + s.description = "GitLab wrapper around git objects" + s.authors = ["Dmitriy Zaporozhets"] + s.email = 'dmitriy.zaporozhets@gmail.com' + s.license = 'MIT' + s.files = `git ls-files lib/`.split('\n') << 'VERSION' + s.homepage = 'https://gitlab.com/gitlab-org/gitlab_git' + + s.add_dependency('github-linguist', '~> 4.7.0') + s.add_dependency('activesupport', '~> 4.0') + s.add_dependency('rugged', '~> 0.24.0') + s.add_runtime_dependency('charlock_holmes', '~> 0.7.3') + s.add_development_dependency('listen', '~> 3.0.6') + end + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the gem name' do + expect(subject).to include(link('gitlab_git', 'https://rubygems.org/gems/gitlab_git')) + end + + it 'links the license' do + expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/')) + end + + it 'links the homepage' do + expect(subject).to include(link('https://gitlab.com/gitlab-org/gitlab_git', 'https://gitlab.com/gitlab-org/gitlab_git')) + end + + it 'links dependencies' do + expect(subject).to include(link('github-linguist', 'https://rubygems.org/gems/github-linguist')) + expect(subject).to include(link('activesupport', 'https://rubygems.org/gems/activesupport')) + expect(subject).to include(link('rugged', 'https://rubygems.org/gems/rugged')) + expect(subject).to include(link('charlock_holmes', 'https://rubygems.org/gems/charlock_holmes')) + expect(subject).to include(link('listen', 'https://rubygems.org/gems/listen')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/godeps_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/godeps_json_linker_spec.rb new file mode 100644 index 00000000000..e279e0c9019 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/godeps_json_linker_spec.rb @@ -0,0 +1,84 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::GodepsJsonLinker, lib: true do + describe '.support?' do + it 'supports Godeps.json' do + expect(described_class.support?('Godeps.json')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('Godeps.json.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "Godeps.json" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + { + "ImportPath": "gitlab.com/gitlab-org/gitlab-pages", + "GoVersion": "go1.5", + "Packages": [ + "./..." + ], + "Deps": [ + { + "ImportPath": "github.com/kardianos/osext", + "Rev": "efacde03154693404c65e7aa7d461ac9014acd0c" + }, + { + "ImportPath": "github.com/stretchr/testify/assert", + "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b" + }, + { + "ImportPath": "github.com/stretchr/testify/require", + "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b" + }, + { + "ImportPath": "gitlab.com/group/project/path", + "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b" + }, + { + "ImportPath": "gitlab.com/group/subgroup/project.git/path", + "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b" + }, + { + "ImportPath": "golang.org/x/crypto/ssh/terminal", + "Rev": "1351f936d976c60a0a48d728281922cf63eafb8d" + }, + { + "ImportPath": "golang.org/x/net/http2", + "Rev": "b4e17d61b15679caf2335da776c614169a1b4643" + } + ] + } + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the package name' do + expect(subject).to include(link('gitlab.com/gitlab-org/gitlab-pages', 'https://gitlab.com/gitlab-org/gitlab-pages')) + end + + it 'links GitHub repos' do + expect(subject).to include(link('github.com/kardianos/osext', 'https://github.com/kardianos/osext')) + expect(subject).to include(link('github.com/stretchr/testify/assert', 'https://github.com/stretchr/testify/tree/master/assert')) + expect(subject).to include(link('github.com/stretchr/testify/require', 'https://github.com/stretchr/testify/tree/master/require')) + end + + it 'links GitLab projects' do + expect(subject).to include(link('gitlab.com/group/project/path', 'https://gitlab.com/group/project/tree/master/path')) + expect(subject).to include(link('gitlab.com/group/subgroup/project.git/path', 'https://gitlab.com/group/subgroup/project/tree/master/path')) + end + + it 'links Golang packages' do + expect(subject).to include(link('golang.org/x/net/http2', 'https://godoc.org/golang.org/x/net/http2')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb new file mode 100644 index 00000000000..8c979ae1869 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb @@ -0,0 +1,94 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::PackageJsonLinker, lib: true do + describe '.support?' do + it 'supports package.json' do + expect(described_class.support?('package.json')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('package.json.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "package.json" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + { + "name": "module-name", + "version": "10.3.1", + "repository": { + "type": "git", + "url": "https://github.com/vuejs/vue.git" + }, + "homepage": "https://github.com/vuejs/vue#readme", + "scripts": { + "karma": "karma start config/karma.config.js --single-run" + }, + "dependencies": { + "primus": "*", + "async": "~0.8.0", + "express": "4.2.x", + "bigpipe": "bigpipe/pagelet", + "plates": "https://github.com/flatiron/plates/tarball/master", + "karma": "^1.4.1" + }, + "devDependencies": { + "vows": "^0.7.0", + "assume": "<1.0.0 || >=2.3.1 <2.4.5 || >=2.5.2 <3.0.0", + "pre-commit": "*" + }, + "license": "MIT" + } + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the module name' do + expect(subject).to include(link('module-name', 'https://npmjs.com/package/module-name')) + end + + it 'links the homepage' do + expect(subject).to include(link('https://github.com/vuejs/vue#readme', 'https://github.com/vuejs/vue#readme')) + end + + it 'links the repository URL' do + expect(subject).to include(link('https://github.com/vuejs/vue.git', 'https://github.com/vuejs/vue.git')) + end + + it 'links the license' do + expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/')) + end + + it 'links dependencies' do + expect(subject).to include(link('primus', 'https://npmjs.com/package/primus')) + expect(subject).to include(link('async', 'https://npmjs.com/package/async')) + expect(subject).to include(link('express', 'https://npmjs.com/package/express')) + expect(subject).to include(link('bigpipe', 'https://npmjs.com/package/bigpipe')) + expect(subject).to include(link('plates', 'https://npmjs.com/package/plates')) + expect(subject).to include(link('karma', 'https://npmjs.com/package/karma')) + expect(subject).to include(link('vows', 'https://npmjs.com/package/vows')) + expect(subject).to include(link('assume', 'https://npmjs.com/package/assume')) + expect(subject).to include(link('pre-commit', 'https://npmjs.com/package/pre-commit')) + end + + it 'links GitHub repos' do + expect(subject).to include(link('bigpipe/pagelet', 'https://github.com/bigpipe/pagelet')) + end + + it 'links Git repos' do + expect(subject).to include(link('https://github.com/flatiron/plates/tarball/master', 'https://github.com/flatiron/plates/tarball/master')) + end + + it 'does not link scripts with the same key as a package' do + expect(subject).not_to include(link('karma start config/karma.config.js --single-run', 'https://github.com/karma start config/karma.config.js --single-run')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/podfile_linker_spec.rb b/spec/lib/gitlab/dependency_linker/podfile_linker_spec.rb new file mode 100644 index 00000000000..06007cf97f7 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/podfile_linker_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::PodfileLinker, lib: true do + describe '.support?' do + it 'supports Podfile' do + expect(described_class.support?('Podfile')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('Podfile.lock')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "Podfile" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + source 'https://github.com/artsy/Specs.git' + source 'https://github.com/CocoaPods/Specs.git' + + platform :ios, '8.0' + use_frameworks! + inhibit_all_warnings! + + target 'Artsy' do + pod 'AFNetworking', "~> 2.5" + pod 'Interstellar/Core', git: 'https://github.com/ashfurrow/Interstellar.git', branch: 'observable-unsubscribe' + end + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links sources' do + expect(subject).to include(link('https://github.com/artsy/Specs.git', 'https://github.com/artsy/Specs.git')) + expect(subject).to include(link('https://github.com/CocoaPods/Specs.git', 'https://github.com/CocoaPods/Specs.git')) + end + + it 'links packages' do + expect(subject).to include(link('AFNetworking', 'https://cocoapods.org/pods/AFNetworking')) + expect(subject).to include(link('Interstellar/Core', 'https://cocoapods.org/pods/Interstellar')) + end + + it 'links Git repos' do + expect(subject).to include(link('https://github.com/ashfurrow/Interstellar.git', 'https://github.com/ashfurrow/Interstellar.git')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/podspec_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/podspec_json_linker_spec.rb new file mode 100644 index 00000000000..d722865264b --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/podspec_json_linker_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::PodspecJsonLinker, lib: true do + describe '.support?' do + it 'supports *.podspec.json' do + expect(described_class.support?('Reachability.podspec.json')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('.podspec.json.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "AFNetworking.podspec.json" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + { + "name": "AFNetworking", + "version": "2.0.0", + "license": "MIT", + "summary": "A delightful iOS and OS X networking framework.", + "homepage": "https://github.com/AFNetworking/AFNetworking", + "authors": { + "Mattt Thompson": "m@mattt.me" + }, + "source": { + "git": "https://github.com/AFNetworking/AFNetworking.git", + "tag": "2.0.0", + "submodules": true + }, + "requires_arc": true, + "platforms": { + "ios": "6.0", + "osx": "10.8" + }, + "public_header_files": "AFNetworking/*.h", + "subspecs": [ + { + "name": "NSURLConnection", + "dependencies": { + "AFNetworking/Serialization": [ + + ], + "AFNetworking/Reachability": [ + + ], + "AFNetworking/Security": [ + + ] + }, + "source_files": [ + "AFNetworking/AFURLConnectionOperation.{h,m}", + "AFNetworking/AFHTTPRequestOperation.{h,m}", + "AFNetworking/AFHTTPRequestOperationManager.{h,m}" + ] + } + ] + } + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the gem name' do + expect(subject).to include(link('AFNetworking', 'https://cocoapods.org/pods/AFNetworking')) + end + + it 'links the license' do + expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/')) + end + + it 'links the homepage' do + expect(subject).to include(link('https://github.com/AFNetworking/AFNetworking', 'https://github.com/AFNetworking/AFNetworking')) + end + + it 'links the source URL' do + expect(subject).to include(link('https://github.com/AFNetworking/AFNetworking.git', 'https://github.com/AFNetworking/AFNetworking.git')) + end + + it 'links dependencies' do + expect(subject).to include(link('AFNetworking/Serialization', 'https://cocoapods.org/pods/AFNetworking')) + expect(subject).to include(link('AFNetworking/Reachability', 'https://cocoapods.org/pods/AFNetworking')) + expect(subject).to include(link('AFNetworking/Security', 'https://cocoapods.org/pods/AFNetworking')) + end + + it 'does not link subspec names' do + expect(subject).not_to include(link('NSURLConnection', 'https://cocoapods.org/pods/NSURLConnection')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/podspec_linker_spec.rb b/spec/lib/gitlab/dependency_linker/podspec_linker_spec.rb new file mode 100644 index 00000000000..dfc366b5817 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/podspec_linker_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::PodspecLinker, lib: true do + describe '.support?' do + it 'supports *.podspec' do + expect(described_class.support?('Reachability.podspec')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('.podspec.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "Reachability.podspec" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + Pod::Spec.new do |spec| + spec.name = 'Reachability' + spec.version = '3.1.0' + spec.license = { :type => 'GPL-3.0' } + spec.license = "MIT" + spec.license = { type: 'Apache-2.0' } + spec.homepage = 'https://github.com/tonymillion/Reachability' + spec.authors = { 'Tony Million' => 'tonymillion@gmail.com' } + spec.summary = 'ARC and GCD Compatible Reachability Class for iOS and OS X.' + spec.source = { :git => 'https://github.com/tonymillion/Reachability.git', :tag => 'v3.1.0' } + spec.source_files = 'Reachability.{h,m}' + spec.framework = 'SystemConfiguration' + + spec.dependency 'AFNetworking', '~> 1.0' + spec.dependency 'RestKit/CoreData', '~> 0.20.0' + spec.ios.dependency 'MBProgressHUD', '~> 0.5' + end + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the gem name' do + expect(subject).to include(link('Reachability', 'https://cocoapods.org/pods/Reachability')) + end + + it 'links the license' do + expect(subject).to include(link('GPL-3.0', 'http://choosealicense.com/licenses/gpl-3.0/')) + expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/')) + expect(subject).to include(link('Apache-2.0', 'http://choosealicense.com/licenses/apache-2.0/')) + end + + it 'links the homepage' do + expect(subject).to include(link('https://github.com/tonymillion/Reachability', 'https://github.com/tonymillion/Reachability')) + end + + it 'links the source URL' do + expect(subject).to include(link('https://github.com/tonymillion/Reachability.git', 'https://github.com/tonymillion/Reachability.git')) + end + + it 'links dependencies' do + expect(subject).to include(link('AFNetworking', 'https://cocoapods.org/pods/AFNetworking')) + expect(subject).to include(link('RestKit/CoreData', 'https://cocoapods.org/pods/RestKit')) + expect(subject).to include(link('MBProgressHUD', 'https://cocoapods.org/pods/MBProgressHUD')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb b/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb new file mode 100644 index 00000000000..4da8821726c --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb @@ -0,0 +1,87 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::RequirementsTxtLinker, lib: true do + describe '.support?' do + it 'supports requirements.txt' do + expect(described_class.support?('requirements.txt')).to be_truthy + end + + it 'supports doc-requirements.txt' do + expect(described_class.support?('doc-requirements.txt')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('requirements')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "requirements.txt" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + # + ####### example-requirements.txt ####### + # + ###### Requirements without Version Specifiers ###### + nose + nose-cov + beautifulsoup4 + # + ###### Requirements with Version Specifiers ###### + # See https://www.python.org/dev/peps/pep-0440/#version-specifiers + docopt == 0.6.1 # Version Matching. Must be version 0.6.1 + keyring >= 4.1.1 # Minimum version 4.1.1 + coverage != 3.5 # Version Exclusion. Anything except version 3.5 + Mopidy-Dirble ~= 1.1 # Compatible release. Same as >= 1.1, == 1.* + # + ###### Refer to other requirements files ###### + -r other-requirements.txt + # + # + ###### A particular file ###### + ./downloads/numpy-1.9.2-cp34-none-win32.whl + http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl + # + ###### Additional Requirements without Version Specifiers ###### + # Same as 1st section, just here to show that you can put things in any order. + rejected + green + # + + Jinja2>=2.3 + Pygments>=1.2 + Sphinx>=1.3 + docutils>=0.7 + markupsafe + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links dependencies' do + expect(subject).to include(link('nose', 'https://pypi.python.org/pypi/nose')) + expect(subject).to include(link('nose-cov', 'https://pypi.python.org/pypi/nose-cov')) + expect(subject).to include(link('beautifulsoup4', 'https://pypi.python.org/pypi/beautifulsoup4')) + expect(subject).to include(link('docopt', 'https://pypi.python.org/pypi/docopt')) + expect(subject).to include(link('keyring', 'https://pypi.python.org/pypi/keyring')) + expect(subject).to include(link('coverage', 'https://pypi.python.org/pypi/coverage')) + expect(subject).to include(link('Mopidy-Dirble', 'https://pypi.python.org/pypi/Mopidy-Dirble')) + expect(subject).to include(link('rejected', 'https://pypi.python.org/pypi/rejected')) + expect(subject).to include(link('green', 'https://pypi.python.org/pypi/green')) + expect(subject).to include(link('Jinja2', 'https://pypi.python.org/pypi/Jinja2')) + expect(subject).to include(link('Pygments', 'https://pypi.python.org/pypi/Pygments')) + expect(subject).to include(link('Sphinx', 'https://pypi.python.org/pypi/Sphinx')) + expect(subject).to include(link('docutils', 'https://pypi.python.org/pypi/docutils')) + expect(subject).to include(link('markupsafe', 'https://pypi.python.org/pypi/markupsafe')) + end + + it 'links URLs' do + expect(subject).to include(link('http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl', 'http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker_spec.rb b/spec/lib/gitlab/dependency_linker_spec.rb new file mode 100644 index 00000000000..3d1cfbcfbf7 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker_spec.rb @@ -0,0 +1,85 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker, lib: true do + describe '.link' do + it 'links using GemfileLinker' do + blob_name = 'Gemfile' + + expect(described_class::GemfileLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using GemspecLinker' do + blob_name = 'gitlab_git.gemspec' + + expect(described_class::GemspecLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using PackageJsonLinker' do + blob_name = 'package.json' + + expect(described_class::PackageJsonLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using ComposerJsonLinker' do + blob_name = 'composer.json' + + expect(described_class::ComposerJsonLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using PodfileLinker' do + blob_name = 'Podfile' + + expect(described_class::PodfileLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using PodspecLinker' do + blob_name = 'Reachability.podspec' + + expect(described_class::PodspecLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using PodspecJsonLinker' do + blob_name = 'AFNetworking.podspec.json' + + expect(described_class::PodspecJsonLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using CartfileLinker' do + blob_name = 'Cartfile' + + expect(described_class::CartfileLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using GodepsJsonLinker' do + blob_name = 'Godeps.json' + + expect(described_class::GodepsJsonLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using RequirementsTxtLinker' do + blob_name = 'requirements.txt' + + expect(described_class::RequirementsTxtLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + end +end diff --git a/spec/lib/gitlab/diff/diff_refs_spec.rb b/spec/lib/gitlab/diff/diff_refs_spec.rb new file mode 100644 index 00000000000..a8173558c00 --- /dev/null +++ b/spec/lib/gitlab/diff/diff_refs_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe Gitlab::Diff::DiffRefs, lib: true do + let(:project) { create(:project, :repository) } + + describe '#compare_in' do + context 'with diff refs for the initial commit' do + let(:commit) { project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') } + subject { commit.diff_refs } + + it 'returns an appropriate comparison' do + compare = subject.compare_in(project) + + expect(compare.diff_refs).to eq(subject) + end + end + + context 'with diff refs for a commit' do + let(:commit) { project.commit('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } + subject { commit.diff_refs } + + it 'returns an appropriate comparison' do + compare = subject.compare_in(project) + + expect(compare.diff_refs).to eq(subject) + end + end + + context 'with diff refs for a comparison through the base' do + subject do + described_class.new( + start_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', # feature + base_sha: 'ae73cb07c9eeaf35924a10f713b364d32b2dd34f', + head_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a' # master + ) + end + + it 'returns an appropriate comparison' do + compare = subject.compare_in(project) + + expect(compare.diff_refs).to eq(subject) + end + end + + context 'with diff refs for a straight comparison' do + subject do + described_class.new( + start_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', # feature + base_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', + head_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a' # master + ) + end + + it 'returns an appropriate comparison' do + compare = subject.compare_in(project) + + expect(compare.diff_refs).to eq(subject) + end + end + end +end diff --git a/spec/lib/gitlab/diff/highlight_spec.rb b/spec/lib/gitlab/diff/highlight_spec.rb index c6bd4e81f4f..7d7d4a55e63 100644 --- a/spec/lib/gitlab/diff/highlight_spec.rb +++ b/spec/lib/gitlab/diff/highlight_spec.rb @@ -34,7 +34,7 @@ describe Gitlab::Diff::Highlight, lib: true do end it 'highlights and marks added lines' do - code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class='idiff left'>RuntimeError</span></span><span class="p"><span class='idiff'>,</span></span><span class='idiff right'> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n} + code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class="idiff left">RuntimeError</span></span><span class="p"><span class="idiff">,</span></span><span class="idiff right"> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n} expect(subject[5].text).to eq(code) end @@ -67,7 +67,7 @@ describe Gitlab::Diff::Highlight, lib: true do end it 'marks added lines' do - code = %q{+ raise <span class='idiff left right'>RuntimeError, </span>"System commands must be given as an array of strings"} + code = %q{+ raise <span class="idiff left right">RuntimeError, </span>"System commands must be given as an array of strings"} expect(subject[5].text).to eq(code) expect(subject[5].text).to be_html_safe diff --git a/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb b/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb new file mode 100644 index 00000000000..d6e8b8ac4b2 --- /dev/null +++ b/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe Gitlab::Diff::InlineDiffMarkdownMarker, lib: true do + describe '#mark' do + let(:raw) { "abc 'def'" } + let(:inline_diffs) { [2..5] } + let(:subject) { described_class.new(raw).mark(inline_diffs, mode: :deletion) } + + it 'marks the range' do + expect(subject).to eq("ab{-c 'd-}ef'") + expect(subject).to be_html_safe + end + end +end diff --git a/spec/lib/gitlab/diff/inline_diff_marker_spec.rb b/spec/lib/gitlab/diff/inline_diff_marker_spec.rb index 198ff977f24..95da344802d 100644 --- a/spec/lib/gitlab/diff/inline_diff_marker_spec.rb +++ b/spec/lib/gitlab/diff/inline_diff_marker_spec.rb @@ -1,26 +1,26 @@ require 'spec_helper' describe Gitlab::Diff::InlineDiffMarker, lib: true do - describe '#inline_diffs' do + describe '#mark' do context "when the rich text is html safe" do - let(:raw) { "abc 'def'" } + let(:raw) { "abc 'def'" } let(:rich) { %{<span class="abc">abc</span><span class="space"> </span><span class="def">'def'</span>}.html_safe } let(:inline_diffs) { [2..5] } - let(:subject) { Gitlab::Diff::InlineDiffMarker.new(raw, rich).mark(inline_diffs) } + let(:subject) { described_class.new(raw, rich).mark(inline_diffs) } - it 'marks the inline diffs' do - expect(subject).to eq(%{<span class="abc">ab<span class='idiff left'>c</span></span><span class="space"><span class='idiff'> </span></span><span class="def"><span class='idiff right'>'d</span>ef'</span>}) + it 'marks the range' do + expect(subject).to eq(%{<span class="abc">ab<span class="idiff left">c</span></span><span class="space"><span class="idiff"> </span></span><span class="def"><span class="idiff right">'d</span>ef'</span>}) expect(subject).to be_html_safe end end context "when the text text is not html safe" do - let(:raw) { "abc 'def'" } + let(:raw) { "abc 'def'" } let(:inline_diffs) { [2..5] } - let(:subject) { Gitlab::Diff::InlineDiffMarker.new(raw).mark(inline_diffs) } + let(:subject) { described_class.new(raw).mark(inline_diffs) } - it 'marks the inline diffs' do - expect(subject).to eq(%{ab<span class='idiff left right'>c 'd</span>ef'}) + it 'marks the range' do + expect(subject).to eq(%{ab<span class="idiff left right">c 'd</span>ef'}) expect(subject).to be_html_safe end end diff --git a/spec/lib/gitlab/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb index cdf0af6d7ef..b3d46e69ccb 100644 --- a/spec/lib/gitlab/diff/position_spec.rb +++ b/spec/lib/gitlab/diff/position_spec.rb @@ -22,7 +22,7 @@ describe Gitlab::Diff::Position, lib: true 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_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 @@ -314,7 +314,7 @@ describe Gitlab::Diff::Position, lib: true do it "returns the correct diff file" do diff_file = subject.diff_file(project.repository) - expect(diff_file.deleted_file).to be true + expect(diff_file.deleted_file?).to be true expect(diff_file.old_path).to eq(subject.old_path) expect(diff_file.diff_refs).to eq(subject.diff_refs) end @@ -356,7 +356,7 @@ describe Gitlab::Diff::Position, lib: true 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_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 @@ -381,6 +381,54 @@ describe Gitlab::Diff::Position, lib: true do end end + describe "position for a file in a straight comparison" do + let(:diff_refs) do + Gitlab::Diff::DiffRefs.new( + start_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', # feature + base_sha: '0b4bc9a49b562e85de7cc9e834518ea6828729b9', + head_sha: 'e63f41fe459e62e1228fcef60d7189127aeba95a' # master + ) + end + + subject do + described_class.new( + old_path: "files/ruby/feature.rb", + new_path: "files/ruby/feature.rb", + old_line: 3, + new_line: nil, + diff_refs: diff_refs + ) + end + + describe "#diff_file" do + it "returns the correct diff file" do + diff_file = subject.diff_file(project.repository) + + expect(diff_file.deleted_file?).to be true + expect(diff_file.old_path).to eq(subject.old_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.removed?).to be true + expect(diff_line.old_line).to eq(subject.old_line) + expect(diff_line.text).to eq("- puts 'bar'") + end + end + + describe "#line_code" do + it "returns the correct line code" do + line_code = Gitlab::Diff::LineCode.generate(subject.file_path, 0, subject.old_line) + + 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/diff/position_tracer_spec.rb b/spec/lib/gitlab/diff/position_tracer_spec.rb index a10a251dc4a..93d30b90937 100644 --- a/spec/lib/gitlab/diff/position_tracer_spec.rb +++ b/spec/lib/gitlab/diff/position_tracer_spec.rb @@ -61,9 +61,10 @@ describe Gitlab::Diff::PositionTracer, lib: true do let(:old_diff_refs) { raise NotImplementedError } let(:new_diff_refs) { raise NotImplementedError } + let(:change_diff_refs) { raise NotImplementedError } let(:old_position) { raise NotImplementedError } - let(:position_tracer) { described_class.new(repository: project.repository, old_diff_refs: old_diff_refs, new_diff_refs: new_diff_refs) } + let(:position_tracer) { described_class.new(project: project, old_diff_refs: old_diff_refs, new_diff_refs: new_diff_refs) } subject { position_tracer.trace(old_position) } def diff_refs(base_commit, head_commit) @@ -77,16 +78,40 @@ describe Gitlab::Diff::PositionTracer, lib: true do Gitlab::Diff::Position.new(attrs) end - def expect_new_position(attrs, new_position = subject) - if attrs.nil? - expect(new_position).to be_nil - else - expect(new_position).not_to be_nil + def expect_new_position(attrs, result = subject) + aggregate_failures("expect new position #{attrs.inspect}") do + if attrs.nil? + expect(result[:outdated]).to be_truthy + else + expect(result[:outdated]).to be_falsey - expect(new_position.diff_refs).to eq(new_diff_refs) + new_position = result[:position] + expect(new_position).not_to be_nil - attrs.each do |attr, value| - expect(new_position.send(attr)).to eq(value) + expect(new_position.diff_refs).to eq(new_diff_refs) + + attrs.each do |attr, value| + expect(new_position.send(attr)).to eq(value) + end + end + end + end + + def expect_change_position(attrs, result = subject) + aggregate_failures("expect change position #{attrs.inspect}") do + expect(result[:outdated]).to be_truthy + + change_position = result[:position] + if attrs.nil? || attrs.empty? + expect(change_position).to be_nil + else + expect(change_position).not_to be_nil + + expect(change_position.diff_refs).to eq(change_diff_refs) + + attrs.each do |attr, value| + expect(change_position.send(attr)).to eq(value) + end end end end @@ -395,6 +420,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when that line was changed between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) } let(:new_diff_refs) { diff_refs(initial_commit, update_line_commit) } + let(:change_diff_refs) { diff_refs(create_file_commit, update_line_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -407,14 +433,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 + BB # 3 + C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 2, + new_line: nil + ) end end context "when that line was deleted between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, update_line_commit) } let(:new_diff_refs) { diff_refs(initial_commit, delete_line_commit) } + let(:change_diff_refs) { diff_refs(update_line_commit, delete_line_commit) } let(:old_position) { position(new_path: file_name, new_line: 3) } # old diff: @@ -426,8 +458,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 1 + A # 2 + BB - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 3, + new_line: nil + ) end end end @@ -512,6 +549,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when that line was changed between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) } let(:new_diff_refs) { diff_refs(create_file_commit, update_line_commit) } + let(:change_diff_refs) { diff_refs(create_file_commit, update_line_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -525,14 +563,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 + BB # 3 3 C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 2, + new_line: nil + ) end end context "when that line was deleted between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, move_line_commit) } let(:new_diff_refs) { diff_refs(move_line_commit, delete_line_commit) } + let(:change_diff_refs) { diff_refs(move_line_commit, delete_line_commit) } let(:old_position) { position(new_path: file_name, new_line: 3) } # old diff: @@ -545,8 +589,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 2 A # 3 - C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 3, + new_line: nil + ) end end end @@ -558,6 +607,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when the file's content was unchanged between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, delete_line_commit) } let(:new_diff_refs) { diff_refs(delete_line_commit, rename_file_commit) } + let(:change_diff_refs) { diff_refs(initial_commit, delete_line_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -569,8 +619,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 1 1 BB # 2 2 A - it "returns nil since the line doesn't exist in the new diffs anymore" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: nil, + new_line: 2 + ) end end @@ -628,6 +683,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when that line was changed between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, delete_line_commit) } let(:new_diff_refs) { diff_refs(delete_line_commit, update_line_again_commit) } + let(:change_diff_refs) { diff_refs(delete_line_commit, update_line_again_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -640,28 +696,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 - A # 2 + AA - it "returns nil" do - expect(subject).to be_nil - end - end - - context "when that line was deleted between the old and the new diff" do - let(:old_diff_refs) { diff_refs(initial_commit, delete_line_commit) } - let(:new_diff_refs) { diff_refs(delete_line_commit, delete_line_again_commit) } - let(:old_position) { position(new_path: file_name, new_line: 1) } - - # old diff: - # 1 + BB - # 2 + A - # - # new diff: - # file_name -> new_file_name - # 1 - BB - # 2 - A - # 1 + AA - - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: new_file_name, + old_line: 2, + new_line: nil + ) end end end @@ -673,6 +714,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when the file's content was unchanged between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, delete_line_commit) } let(:new_diff_refs) { diff_refs(delete_line_commit, delete_file_commit) } + let(:change_diff_refs) { diff_refs(delete_line_commit, delete_file_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -683,8 +725,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 1 - BB # 2 - A - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 2, + new_line: nil + ) end end @@ -692,6 +739,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when that line was unchanged between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, move_line_commit) } let(:new_diff_refs) { diff_refs(delete_line_commit, delete_file_commit) } + let(:change_diff_refs) { diff_refs(move_line_commit, delete_file_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -703,14 +751,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 1 - BB # 2 - A - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 2, + new_line: nil + ) end end context "when that line was moved between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, update_line_commit) } let(:new_diff_refs) { diff_refs(move_line_commit, delete_file_commit) } + let(:change_diff_refs) { diff_refs(update_line_commit, delete_file_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -723,14 +777,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 - A # 3 - C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 2, + new_line: nil + ) end end context "when that line was changed between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) } let(:new_diff_refs) { diff_refs(update_line_commit, delete_file_commit) } + let(:change_diff_refs) { diff_refs(create_file_commit, delete_file_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -743,14 +803,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 - BB # 3 - C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 2, + new_line: nil + ) end end context "when that line was deleted between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, move_line_commit) } let(:new_diff_refs) { diff_refs(delete_line_commit, delete_file_commit) } + let(:change_diff_refs) { diff_refs(move_line_commit, delete_file_commit) } let(:old_position) { position(new_path: file_name, new_line: 3) } # old diff: @@ -762,8 +828,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 1 - BB # 2 - A - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 3, + new_line: nil + ) end end end @@ -775,6 +846,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when the file's content was unchanged between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) } let(:new_diff_refs) { diff_refs(create_file_commit, create_second_file_commit) } + let(:change_diff_refs) { diff_refs(initial_commit, create_file_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -787,8 +859,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 2 B # 3 3 C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: nil, + new_line: 2 + ) end end @@ -796,6 +873,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when that line was unchanged between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) } let(:new_diff_refs) { diff_refs(update_line_commit, update_second_file_line_commit) } + let(:change_diff_refs) { diff_refs(initial_commit, update_line_commit) } let(:old_position) { position(new_path: file_name, new_line: 1) } # old diff: @@ -808,14 +886,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 2 BB # 3 3 C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: nil, + new_line: 1 + ) end end context "when that line was moved between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, update_line_commit) } let(:new_diff_refs) { diff_refs(move_line_commit, move_second_file_line_commit) } + let(:change_diff_refs) { diff_refs(initial_commit, move_line_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -828,14 +912,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 2 A # 3 3 C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: nil, + new_line: 1 + ) end end context "when that line was changed between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, create_file_commit) } let(:new_diff_refs) { diff_refs(update_line_commit, update_second_file_line_commit) } + let(:change_diff_refs) { diff_refs(create_file_commit, update_second_file_line_commit) } let(:old_position) { position(new_path: file_name, new_line: 2) } # old diff: @@ -848,14 +938,20 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 2 BB # 3 3 C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 2, + new_line: nil + ) end end context "when that line was deleted between the old and the new diff" do let(:old_diff_refs) { diff_refs(initial_commit, move_line_commit) } let(:new_diff_refs) { diff_refs(delete_line_commit, delete_second_file_line_commit) } + let(:change_diff_refs) { diff_refs(move_line_commit, delete_second_file_line_commit) } let(:old_position) { position(new_path: file_name, new_line: 3) } # old diff: @@ -867,8 +963,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 1 1 BB # 2 2 A - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 3, + new_line: nil + ) end end end @@ -957,6 +1058,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when that line was changed or deleted between the old and the new diff" do let(:old_diff_refs) { diff_refs(create_file_commit, move_line_commit) } let(:new_diff_refs) { diff_refs(initial_commit, create_file_commit) } + let(:change_diff_refs) { diff_refs(move_line_commit, create_file_commit) } let(:old_position) { position(old_path: file_name, new_path: file_name, new_line: 1) } # old diff: @@ -970,8 +1072,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 + B # 3 + C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 1, + new_line: nil + ) end end end @@ -980,6 +1087,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when the position pointed at a deleted line in the old diff" do let(:old_diff_refs) { diff_refs(create_file_commit, update_line_commit) } let(:new_diff_refs) { diff_refs(initial_commit, update_line_commit) } + let(:change_diff_refs) { diff_refs(create_file_commit, initial_commit) } let(:old_position) { position(old_path: file_name, new_path: file_name, old_line: 2) } # old diff: @@ -993,8 +1101,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 + BB # 3 + C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 2, + new_line: nil + ) end end @@ -1076,6 +1189,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when that line was changed or deleted between the old and the new diff" do let(:old_diff_refs) { diff_refs(create_file_commit, move_line_commit) } let(:new_diff_refs) { diff_refs(initial_commit, delete_line_commit) } + let(:change_diff_refs) { diff_refs(move_line_commit, delete_line_commit) } let(:old_position) { position(old_path: file_name, new_path: file_name, old_line: 3, new_line: 3) } # old diff: @@ -1088,8 +1202,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 1 + A # 2 + B - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 3, + new_line: nil + ) end end end @@ -1182,6 +1301,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do context "when that line was changed or deleted between the old and the new diff" do let(:old_diff_refs) { diff_refs(create_file_commit, move_line_commit) } let(:new_diff_refs) { diff_refs(create_file_commit, update_line_commit) } + let(:change_diff_refs) { diff_refs(move_line_commit, update_line_commit) } let(:old_position) { position(old_path: file_name, new_path: file_name, new_line: 1) } # old diff: @@ -1196,8 +1316,13 @@ describe Gitlab::Diff::PositionTracer, lib: true do # 2 + BB # 3 3 C - it "returns nil" do - expect(subject).to be_nil + it "returns the position of the change" do + expect_change_position( + old_path: file_name, + new_path: file_name, + old_line: 1, + new_line: nil + ) end end end @@ -1239,7 +1364,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do describe "typical use scenarios" do let(:second_branch_name) { "#{branch_name}-2" } - def expect_positions(old_attrs, new_attrs) + def expect_new_positions(old_attrs, new_attrs) old_positions = old_attrs.map do |old_attrs| position(old_attrs) end @@ -1248,8 +1373,14 @@ describe Gitlab::Diff::PositionTracer, lib: true do position_tracer.trace(old_position) end - new_positions.zip(new_attrs).each do |new_position, new_attrs| - expect_new_position(new_attrs, new_position) + aggregate_failures do + new_positions.zip(new_attrs).each do |new_position, new_attrs| + if new_attrs&.delete(:change) + expect_change_position(new_attrs, new_position) + else + expect_new_position(new_attrs, new_position) + end + end end end @@ -1330,6 +1461,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do describe "simple push of new commit" do let(:old_diff_refs) { diff_refs(create_file_commit, update_file_commit) } let(:new_diff_refs) { diff_refs(create_file_commit, update_file_again_commit) } + let(:change_diff_refs) { diff_refs(update_file_commit, update_file_again_commit) } # old diff: # 1 1 A @@ -1368,14 +1500,14 @@ describe Gitlab::Diff::PositionTracer, lib: true do { old_path: file_name, new_path: file_name, old_line: 1, new_line: 1 }, { old_path: file_name, old_line: 2 }, { old_path: file_name, new_path: file_name, old_line: 3, new_line: 3 }, - { old_path: file_name, old_line: 4, new_line: 4 }, - nil, + { new_path: file_name, new_line: 4, change: true }, + { new_path: file_name, old_line: 3, change: true }, { old_path: file_name, new_path: file_name, old_line: 5, new_line: 5 }, - { old_path: file_name, old_line: 6 }, - { new_path: file_name, new_line: 7 }, + { new_path: file_name, old_line: 5, change: true }, + { new_path: file_name, new_line: 7 } ] - expect_positions(old_position_attrs, new_position_attrs) + expect_new_positions(old_position_attrs, new_position_attrs) end end @@ -1402,6 +1534,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do let(:old_diff_refs) { diff_refs(create_file_commit, update_file_commit) } let(:new_diff_refs) { diff_refs(create_file_commit, second_create_file_commit) } + let(:change_diff_refs) { diff_refs(update_file_commit, second_create_file_commit) } # old diff: # 1 1 A @@ -1440,20 +1573,21 @@ describe Gitlab::Diff::PositionTracer, lib: true do { old_path: file_name, new_path: file_name, old_line: 1, new_line: 1 }, { old_path: file_name, old_line: 2 }, { old_path: file_name, new_path: file_name, old_line: 3, new_line: 3 }, - { old_path: file_name, old_line: 4, new_line: 4 }, - nil, + { new_path: file_name, new_line: 4, change: true }, + { old_path: file_name, old_line: 3, change: true }, { old_path: file_name, new_path: file_name, old_line: 5, new_line: 5 }, - { old_path: file_name, old_line: 6 }, - { new_path: file_name, new_line: 7 }, + { old_path: file_name, old_line: 5, change: true }, + { new_path: file_name, new_line: 7 } ] - expect_positions(old_position_attrs, new_position_attrs) + expect_new_positions(old_position_attrs, new_position_attrs) end end describe "force push to delete last commit" do let(:old_diff_refs) { diff_refs(create_file_commit, update_file_again_commit) } let(:new_diff_refs) { diff_refs(create_file_commit, update_file_commit) } + let(:change_diff_refs) { diff_refs(update_file_again_commit, update_file_commit) } # old diff: # 1 1 A @@ -1492,16 +1626,16 @@ describe Gitlab::Diff::PositionTracer, lib: true do new_position_attrs = [ { old_path: file_name, new_path: file_name, old_line: 1, new_line: 1 }, { old_path: file_name, old_line: 2 }, - nil, + { old_path: file_name, old_line: 2, change: true }, { old_path: file_name, new_path: file_name, old_line: 3, new_line: 2 }, - { old_path: file_name, old_line: 4 }, + { old_path: file_name, old_line: 4, change: true }, { old_path: file_name, new_path: file_name, old_line: 5, new_line: 4 }, - { old_path: file_name, new_path: file_name, old_line: 6, new_line: 5 }, - nil, - { new_path: file_name, new_line: 6 }, + { new_path: file_name, new_line: 5, change: true }, + { old_path: file_name, old_line: 6, change: true }, + { new_path: file_name, new_line: 6 } ] - expect_positions(old_position_attrs, new_position_attrs) + expect_new_positions(old_position_attrs, new_position_attrs) end end @@ -1567,6 +1701,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do let(:old_diff_refs) { diff_refs(create_file_commit, update_file_again_commit) } let(:new_diff_refs) { diff_refs(create_file_commit, overwrite_update_file_again_commit) } + let(:change_diff_refs) { diff_refs(update_file_again_commit, overwrite_update_file_again_commit) } # old diff: # 1 1 A @@ -1618,7 +1753,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do { new_path: file_name, new_line: 10 }, # + G ] - expect_positions(old_position_attrs, new_position_attrs) + expect_new_positions(old_position_attrs, new_position_attrs) end end @@ -1643,6 +1778,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do let(:old_diff_refs) { diff_refs(create_file_commit, update_file_again_commit) } let(:new_diff_refs) { diff_refs(create_file_commit, merge_commit) } + let(:change_diff_refs) { diff_refs(update_file_again_commit, merge_commit) } # old diff: # 1 1 A @@ -1694,13 +1830,14 @@ describe Gitlab::Diff::PositionTracer, lib: true do { new_path: file_name, new_line: 10 }, # + G ] - expect_positions(old_position_attrs, new_position_attrs) + expect_new_positions(old_position_attrs, new_position_attrs) end end describe "changing target branch" do let(:old_diff_refs) { diff_refs(create_file_commit, update_file_again_commit) } let(:new_diff_refs) { diff_refs(update_file_commit, update_file_again_commit) } + let(:change_diff_refs) { diff_refs(create_file_commit, update_file_commit) } # old diff: # 1 1 A @@ -1739,17 +1876,17 @@ describe Gitlab::Diff::PositionTracer, lib: true do new_position_attrs = [ { old_path: file_name, new_path: file_name, old_line: 1, new_line: 1 }, - nil, + { old_path: file_name, old_line: 2, change: true }, { new_path: file_name, new_line: 2 }, { old_path: file_name, new_path: file_name, old_line: 2, new_line: 3 }, { new_path: file_name, new_line: 4 }, { old_path: file_name, new_path: file_name, old_line: 4, new_line: 5 }, { old_path: file_name, old_line: 5 }, { new_path: file_name, new_line: 6 }, - { new_path: file_name, new_line: 7 }, + { new_path: file_name, new_line: 7 } ] - expect_positions(old_position_attrs, new_position_attrs) + expect_new_positions(old_position_attrs, new_position_attrs) end end end diff --git a/spec/lib/gitlab/git/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb index f6ac7b23d1d..1482ef7132d 100644 --- a/spec/lib/gitlab/git/encoding_helper_spec.rb +++ b/spec/lib/gitlab/encoding_helper_spec.rb @@ -1,8 +1,8 @@ require "spec_helper" -describe Gitlab::Git::EncodingHelper do - let(:ext_class) { Class.new { extend Gitlab::Git::EncodingHelper } } - let(:binary_string) { File.join(SEED_STORAGE_PATH, 'gitlab_logo.png') } +describe Gitlab::EncodingHelper do + let(:ext_class) { Class.new { extend Gitlab::EncodingHelper } } + let(:binary_string) { File.read(Rails.root + "spec/fixtures/dk.png") } describe '#encode!' do [ @@ -19,8 +19,8 @@ describe Gitlab::Git::EncodingHelper do [ 'removes invalid bytes from ASCII-8bit encoded multibyte string. This can occur when a git diff match line truncates in the middle of a multibyte character. This occurs after the second word in this example. The test string is as short as we can get while still triggering the error condition when not looking at `detect[:confidence]`.', "mu ns\xC3\n Lorem ipsum dolor sit amet, consectetur adipisicing ut\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg kia elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non p\n {: .normal_pn}\n \n-Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n# *Lorem ipsum\xC3\xB9l\xC3\xB9l\xC3\xA0 dolor\xC3\xB9k\xC3\xB9 sit\xC3\xA8b\xC3\xA8 N\xC3\xA8 amet b\xC3\xA0d\xC3\xAC*\n+# *consectetur\xC3\xB9l\xC3\xB9l\xC3\xA0 adipisicing\xC3\xB9k\xC3\xB9 elit\xC3\xA8b\xC3\xA8 N\xC3\xA8 sed do\xC3\xA0d\xC3\xAC*{: .italic .smcaps}\n \n \xEF\x9B\xA1 eiusmod tempor incididunt, ut\xC3\xAAn\xC3\xB9 labore et dolore. Tw\xC4\x83nj\xC3\xAC magna aliqua. Ut enim ad minim veniam\n {: .normal}\n@@ -9,5 +9,5 @@ quis nostrud\xC3\xAAt\xC3\xB9 exercitiation ullamco laboris m\xC3\xB9s\xC3\xB9k\xC3\xB9abc\xC3\xB9 nisi ".force_encoding('ASCII-8BIT'), - "mu ns\n Lorem ipsum dolor sit amet, consectetur adipisicing ut\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg kia elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non p\n {: .normal_pn}\n \n-Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n# *Lorem ipsum\xC3\xB9l\xC3\xB9l\xC3\xA0 dolor\xC3\xB9k\xC3\xB9 sit\xC3\xA8b\xC3\xA8 N\xC3\xA8 amet b\xC3\xA0d\xC3\xAC*\n+# *consectetur\xC3\xB9l\xC3\xB9l\xC3\xA0 adipisicing\xC3\xB9k\xC3\xB9 elit\xC3\xA8b\xC3\xA8 N\xC3\xA8 sed do\xC3\xA0d\xC3\xAC*{: .italic .smcaps}\n \n \xEF\x9B\xA1 eiusmod tempor incididunt, ut\xC3\xAAn\xC3\xB9 labore et dolore. Tw\xC4\x83nj\xC3\xAC magna aliqua. Ut enim ad minim veniam\n {: .normal}\n@@ -9,5 +9,5 @@ quis nostrud\xC3\xAAt\xC3\xB9 exercitiation ullamco laboris m\xC3\xB9s\xC3\xB9k\xC3\xB9abc\xC3\xB9 nisi ", - ], + "mu ns\n Lorem ipsum dolor sit amet, consectetur adipisicing ut\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg kia elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non p\n {: .normal_pn}\n \n-Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n# *Lorem ipsum\xC3\xB9l\xC3\xB9l\xC3\xA0 dolor\xC3\xB9k\xC3\xB9 sit\xC3\xA8b\xC3\xA8 N\xC3\xA8 amet b\xC3\xA0d\xC3\xAC*\n+# *consectetur\xC3\xB9l\xC3\xB9l\xC3\xA0 adipisicing\xC3\xB9k\xC3\xB9 elit\xC3\xA8b\xC3\xA8 N\xC3\xA8 sed do\xC3\xA0d\xC3\xAC*{: .italic .smcaps}\n \n \xEF\x9B\xA1 eiusmod tempor incididunt, ut\xC3\xAAn\xC3\xB9 labore et dolore. Tw\xC4\x83nj\xC3\xAC magna aliqua. Ut enim ad minim veniam\n {: .normal}\n@@ -9,5 +9,5 @@ quis nostrud\xC3\xAAt\xC3\xB9 exercitiation ullamco laboris m\xC3\xB9s\xC3\xB9k\xC3\xB9abc\xC3\xB9 nisi " + ] ].each do |description, test_string, xpect| it description do expect(ext_class.encode!(test_string)).to eq(xpect) @@ -37,18 +37,18 @@ describe Gitlab::Git::EncodingHelper do [ "encodes valid utf8 encoded string to utf8", "λ, λ, λ".encode("UTF-8"), - "λ, λ, λ".encode("UTF-8"), + "λ, λ, λ".encode("UTF-8") ], [ "encodes valid ASCII-8BIT encoded string to utf8", "ascii only".encode("ASCII-8BIT"), - "ascii only".encode("UTF-8"), + "ascii only".encode("UTF-8") ], [ "encodes valid ISO-8859-1 encoded string to utf8", "Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("ISO-8859-1", "UTF-8"), - "Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("UTF-8"), - ], + "Rüby ist eine Programmiersprache. Wir verlängern den text damit ICU die Sprache erkennen kann.".encode("UTF-8") + ] ].each do |description, test_string, xpect| it description do r = ext_class.encode_utf8(test_string.force_encoding('UTF-8')) @@ -77,8 +77,8 @@ describe Gitlab::Git::EncodingHelper do [ 'removes invalid bytes from ASCII-8bit encoded multibyte string.', "Lorem ipsum\xC3\n dolor sit amet, xy\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg".force_encoding('ASCII-8BIT'), - "Lorem ipsum\n dolor sit amet, xyàyùabcdùefg", - ], + "Lorem ipsum\n dolor sit amet, xyàyùabcdùefg" + ] ].each do |description, test_string, xpect| it description do expect(ext_class.encode!(test_string)).to eq(xpect) diff --git a/spec/lib/gitlab/etag_caching/middleware_spec.rb b/spec/lib/gitlab/etag_caching/middleware_spec.rb index 24df04e985a..3c6ef7c7ccb 100644 --- a/spec/lib/gitlab/etag_caching/middleware_spec.rb +++ b/spec/lib/gitlab/etag_caching/middleware_spec.rb @@ -164,6 +164,25 @@ describe Gitlab::EtagCaching::Middleware do end end + context 'when GitLab instance is using a relative URL' do + before do + mock_app_response + end + + it 'uses full path as cache key' do + env = { + 'PATH_INFO' => enabled_path, + 'SCRIPT_NAME' => '/relative-gitlab' + } + + expect_any_instance_of(Gitlab::EtagCaching::Store) + .to receive(:get).with("/relative-gitlab#{enabled_path}") + .and_return(nil) + + middleware.call(env) + end + end + def mock_app_response allow(app).to receive(:call).and_return([app_status_code, {}, ['body']]) end diff --git a/spec/lib/gitlab/etag_caching/router_spec.rb b/spec/lib/gitlab/etag_caching/router_spec.rb index 410df116a3a..d3c0b4afe35 100644 --- a/spec/lib/gitlab/etag_caching/router_spec.rb +++ b/spec/lib/gitlab/etag_caching/router_spec.rb @@ -2,93 +2,129 @@ require 'spec_helper' describe Gitlab::EtagCaching::Router do it 'matches issue notes endpoint' do - env = build_env( + request = build_request( '/my-group/and-subgroup/here-comes-the-project/noteable/issue/1/notes' ) - result = described_class.match(env) + result = described_class.match(request) expect(result).to be_present expect(result.name).to eq 'issue_notes' end it 'matches issue title endpoint' do - env = build_env( - '/my-group/my-project/issues/123/rendered_title' + request = build_request( + '/my-group/my-project/issues/123/realtime_changes' ) - result = described_class.match(env) + result = described_class.match(request) expect(result).to be_present expect(result.name).to eq 'issue_title' end it 'matches project pipelines endpoint' do - env = build_env( + request = build_request( '/my-group/my-project/pipelines.json' ) - result = described_class.match(env) + result = described_class.match(request) expect(result).to be_present expect(result.name).to eq 'project_pipelines' end it 'matches commit pipelines endpoint' do - env = build_env( + request = build_request( '/my-group/my-project/commit/aa8260d253a53f73f6c26c734c72fdd600f6e6d4/pipelines.json' ) - result = described_class.match(env) + result = described_class.match(request) expect(result).to be_present expect(result.name).to eq 'commit_pipelines' end it 'matches new merge request pipelines endpoint' do - env = build_env( + request = build_request( '/my-group/my-project/merge_requests/new.json' ) - result = described_class.match(env) + result = described_class.match(request) expect(result).to be_present expect(result.name).to eq 'new_merge_request_pipelines' end it 'matches merge request pipelines endpoint' do - env = build_env( + request = build_request( '/my-group/my-project/merge_requests/234/pipelines.json' ) - result = described_class.match(env) + result = described_class.match(request) expect(result).to be_present expect(result.name).to eq 'merge_request_pipelines' end + it 'matches build endpoint' do + request = build_request( + '/my-group/my-project/builds/234.json' + ) + + result = described_class.match(request) + + expect(result).to be_present + expect(result.name).to eq 'project_build' + end + it 'does not match blob with confusing name' do - env = build_env( + request = build_request( '/my-group/my-project/blob/master/pipelines.json' ) - result = described_class.match(env) + result = described_class.match(request) expect(result).to be_blank end +<<<<<<< HEAD it 'matches pipeline#show endpoint' do env = build_env( '/my-group/my-project/pipelines/2.json' ) result = described_class.match(env) +======= + it 'matches the environments path' do + request = build_request( + '/my-group/my-project/environments.json' + ) + + result = described_class.match(request) + expect(result).to be_present + + expect(result.name).to eq 'environments' + end + + it 'matches pipeline#show endpoint' do + request = build_request( + '/my-group/my-project/pipelines/2.json' + ) + + result = described_class.match(request) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(result).to be_present expect(result.name).to eq 'project_pipeline' end +<<<<<<< HEAD def build_env(path) { 'PATH_INFO' => path } +======= + def build_request(path) + double(path_info: path) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end diff --git a/spec/lib/gitlab/file_finder_spec.rb b/spec/lib/gitlab/file_finder_spec.rb new file mode 100644 index 00000000000..5a32ffd462c --- /dev/null +++ b/spec/lib/gitlab/file_finder_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe Gitlab::FileFinder, lib: true do + describe '#find' do + let(:project) { create(:project, :public, :repository) } + let(:finder) { described_class.new(project, project.default_branch) } + + it 'finds by name' do + results = finder.find('files') + expect(results.map(&:first)).to include('files/images/wm.svg') + end + + it 'finds by content' do + results = finder.find('files') + + blob = results.select { |result| result.first == "CHANGELOG" }.flatten.last + + expect(blob.filename).to eq("CHANGELOG") + end + end +end diff --git a/spec/lib/gitlab/git/branch_spec.rb b/spec/lib/gitlab/git/branch_spec.rb index cdf1b8beee3..9eac7660cd1 100644 --- a/spec/lib/gitlab/git/branch_spec.rb +++ b/spec/lib/gitlab/git/branch_spec.rb @@ -7,6 +7,51 @@ describe Gitlab::Git::Branch, seed_helper: true do it { is_expected.to be_kind_of Array } + describe 'initialize' do + let(:commit_id) { 'f00' } + let(:commit_subject) { "My commit".force_encoding('ASCII-8BIT') } + let(:committer) do + Gitaly::FindLocalBranchCommitAuthor.new( + name: generate(:name), + email: generate(:email), + date: Google::Protobuf::Timestamp.new(seconds: 123) + ) + end + let(:author) do + Gitaly::FindLocalBranchCommitAuthor.new( + name: generate(:name), + email: generate(:email), + date: Google::Protobuf::Timestamp.new(seconds: 456) + ) + end + let(:gitaly_branch) do + Gitaly::FindLocalBranchResponse.new( + name: 'foo', commit_id: commit_id, commit_subject: commit_subject, + commit_author: author, commit_committer: committer + ) + end + let(:attributes) do + { + id: commit_id, + message: commit_subject, + authored_date: Time.at(author.date.seconds), + author_name: author.name, + author_email: author.email, + committed_date: Time.at(committer.date.seconds), + committer_name: committer.name, + committer_email: committer.email + } + end + let(:branch) { described_class.new(repository, 'foo', gitaly_branch) } + + it 'parses Gitaly::FindLocalBranchResponse correctly' do + expect(Gitlab::Git::Commit).to receive(:decorate). + with(hash_including(attributes)).and_call_original + + expect(branch.dereferenced_target.message.encoding).to be(Encoding::UTF_8) + end + end + describe '#size' do subject { super().size } it { is_expected.to eq(SeedRepo::Repo::BRANCHES.size) } diff --git a/spec/lib/gitlab/git/compare_spec.rb b/spec/lib/gitlab/git/compare_spec.rb index 7c45071ec45..4c9f4a28f32 100644 --- a/spec/lib/gitlab/git/compare_spec.rb +++ b/spec/lib/gitlab/git/compare_spec.rb @@ -2,8 +2,8 @@ require "spec_helper" describe Gitlab::Git::Compare, seed_helper: true do let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) } - let(:compare) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, false) } - let(:compare_straight) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, true) } + let(:compare) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, straight: false) } + let(:compare_straight) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, straight: true) } describe '#commits' do subject do diff --git a/spec/lib/gitlab/git/diff_collection_spec.rb b/spec/lib/gitlab/git/diff_collection_spec.rb index 122c93dcd69..a9a7bba2c05 100644 --- a/spec/lib/gitlab/git/diff_collection_spec.rb +++ b/spec/lib/gitlab/git/diff_collection_spec.rb @@ -6,18 +6,18 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do iterator, max_files: max_files, max_lines: max_lines, - all_diffs: all_diffs, - no_collapse: no_collapse + limits: limits, + expanded: expanded ) end - let(:iterator) { Array.new(file_count, fake_diff(line_length, line_count)) } + let(:iterator) { MutatingConstantIterator.new(file_count, fake_diff(line_length, line_count)) } let(:file_count) { 0 } let(:line_length) { 1 } let(:line_count) { 1 } let(:max_files) { 10 } let(:max_lines) { 100 } - let(:all_diffs) { false } - let(:no_collapse) { true } + let(:limits) { true } + let(:expanded) { true } describe '#to_a' do subject { super().to_a } @@ -64,10 +64,18 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do subject { super().real_size } it { is_expected.to eq('3') } end - it { expect(subject.size).to eq(3) } + + describe '#size' do + it { expect(subject.size).to eq(3) } + + it 'does not change after peeking' do + subject.any? + expect(subject.size).to eq(3) + end + end context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } describe '#overflow?' do subject { super().overflow? } @@ -83,7 +91,15 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do subject { super().real_size } it { is_expected.to eq('3') } end - it { expect(subject.size).to eq(3) } + + describe '#size' do + it { expect(subject.size).to eq(3) } + + it 'does not change after peeking' do + subject.any? + expect(subject.size).to eq(3) + end + end end end @@ -107,7 +123,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do it { expect(subject.size).to eq(0) } context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } describe '#overflow?' do subject { super().overflow? } @@ -151,7 +167,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do it { expect(subject.size).to eq(10) } context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } describe '#overflow?' do subject { super().overflow? } @@ -191,7 +207,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do it { expect(subject.size).to eq(3) } context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } describe '#overflow?' do subject { super().overflow? } @@ -257,7 +273,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do it { expect(subject.size).to eq(9) } context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } describe '#overflow?' do subject { super().overflow? } @@ -325,10 +341,11 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do end context 'when diff is quite large will collapse by default' do - let(:iterator) { [{ diff: 'a' * 20480 }] } + let(:iterator) { [{ diff: 'a' * (Gitlab::Git::Diff.collapse_limit + 1) }] } + let(:max_files) { 100 } context 'when no collapse is set' do - let(:no_collapse) { true } + let(:expanded) { true } it 'yields Diff instances even when they are quite big' do expect { |b| subject.each(&b) }. @@ -347,7 +364,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do end context 'when no collapse is unset' do - let(:no_collapse) { false } + let(:expanded) { false } it 'yields Diff instances even when they are quite big' do expect { |b| subject.each(&b) }. @@ -434,7 +451,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do end context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } it 'yields Diff instances even when they are quite big' do expect { |b| subject.each(&b) }. @@ -457,4 +474,22 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do def fake_diff(line_length, line_count) { 'diff' => "#{'a' * line_length}\n" * line_count } end + + class MutatingConstantIterator + include Enumerable + + def initialize(count, value) + @count = count + @value = value + end + + def each + loop do + break if @count.zero? + # It is critical to decrement before yielding. We may never reach the lines after 'yield'. + @count -= 1 + yield @value + end + end + end end diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb index 7253a2edeff..da213f617cc 100644 --- a/spec/lib/gitlab/git/diff_spec.rb +++ b/spec/lib/gitlab/git/diff_spec.rb @@ -31,6 +31,36 @@ EOT [".gitmodules"]).patches.first end + describe 'size limit feature toggles' do + context 'when the feature gitlab_git_diff_size_limit_increase is enabled' do + before do + Feature.enable('gitlab_git_diff_size_limit_increase') + end + + it 'returns 200 KB for size_limit' do + expect(described_class.size_limit).to eq(200.kilobytes) + end + + it 'returns 100 KB for collapse_limit' do + expect(described_class.collapse_limit).to eq(100.kilobytes) + end + end + + context 'when the feature gitlab_git_diff_size_limit_increase is disabled' do + before do + Feature.disable('gitlab_git_diff_size_limit_increase') + end + + it 'returns 100 KB for size_limit' do + expect(described_class.size_limit).to eq(100.kilobytes) + end + + it 'returns 10 KB for collapse_limit' do + expect(described_class.collapse_limit).to eq(10.kilobytes) + end + end + end + describe '.new' do context 'using a Hash' do context 'with a small diff' do @@ -47,7 +77,7 @@ EOT context 'using a diff that is too large' do it 'prunes the diff' do - diff = described_class.new(diff: 'a' * 204800) + diff = described_class.new(diff: 'a' * (described_class.size_limit + 1)) expect(diff.diff).to be_empty expect(diff).to be_too_large @@ -85,12 +115,12 @@ EOT # The patch total size is 200, with lines between 21 and 54. # This is a quick-and-dirty way to test this. Ideally, a new patch is # added to the test repo with a size that falls between the real limits. - stub_const("#{described_class}::DIFF_SIZE_LIMIT", 150) - stub_const("#{described_class}::DIFF_COLLAPSE_LIMIT", 100) + allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(150) + allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(100) end it 'prunes the diff as a large diff instead of as a collapsed diff' do - diff = described_class.new(@rugged_diff, collapse: true) + diff = described_class.new(@rugged_diff, expanded: false) expect(diff.diff).to be_empty expect(diff).to be_too_large @@ -110,23 +140,23 @@ EOT end end - context 'using a Gitaly::CommitDiffResponse' do + context 'using a GitalyClient::Diff' do let(:diff) do described_class.new( - Gitaly::CommitDiffResponse.new( + Gitlab::GitalyClient::Diff.new( to_path: ".gitmodules", from_path: ".gitmodules", old_mode: 0100644, new_mode: 0100644, from_id: '357406f3075a57708d0163752905cc1576fceacc', to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', - raw_chunks: raw_chunks, + patch: raw_patch ) ) end context 'with a small diff' do - let(:raw_chunks) { [@raw_diff_hash[:diff]] } + let(:raw_patch) { @raw_diff_hash[:diff] } it 'initializes the diff' do expect(diff.to_hash).to eq(@raw_diff_hash) @@ -138,7 +168,7 @@ EOT end context 'using a diff that is too large' do - let(:raw_chunks) { ['a' * 204800] } + let(:raw_patch) { 'a' * 204800 } it 'prunes the diff' do expect(diff.diff).to be_empty @@ -269,7 +299,7 @@ EOT it 'returns true for a diff that was explicitly marked as being too large' do diff = described_class.new(diff: 'a') - diff.prune_large_diff! + diff.too_large! expect(diff.too_large?).to eq(true) end @@ -291,31 +321,31 @@ EOT it 'returns true for a diff that was explicitly marked as being collapsed' do diff = described_class.new(diff: 'a') - diff.prune_collapsed_diff! + diff.collapse! expect(diff).to be_collapsed end end - describe '#collapsible?' do + describe '#collapsed?' do it 'returns true for a diff that is quite large' do - diff = described_class.new(diff: 'a' * 20480) + diff = described_class.new({ diff: 'a' * (described_class.collapse_limit + 1) }, expanded: false) - expect(diff).to be_collapsible + expect(diff).to be_collapsed end it 'returns false for a diff that is small enough' do - diff = described_class.new(diff: 'a') + diff = described_class.new({ diff: 'a' }, expanded: false) - expect(diff).not_to be_collapsible + expect(diff).not_to be_collapsed end end - describe '#prune_collapsed_diff!' do + describe '#collapse!' do it 'prunes the diff' do diff = described_class.new(diff: "foo\nbar") - diff.prune_collapsed_diff! + diff.collapse! expect(diff.diff).to eq('') expect(diff.line_count).to eq(0) diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 53d492b8f74..26215381cc4 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe Gitlab::Git::Repository, seed_helper: true do - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) } @@ -381,6 +381,19 @@ describe Gitlab::Git::Repository, seed_helper: true do } ]) end + + it 'should not break on invalid syntax' do + allow(repository).to receive(:blob_content).and_return(<<-GITMODULES.strip_heredoc) + [submodule "six"] + path = six + url = git://github.com/randx/six.git + + [submodule] + foo = bar + GITMODULES + + expect(submodules).to have_key('six') + end end context 'where repo doesn\'t have submodules' do @@ -1105,7 +1118,9 @@ describe Gitlab::Git::Repository, seed_helper: true do ref = double() allow(ref).to receive(:name) { 'bad-branch' } allow(ref).to receive(:target) { raise Rugged::ReferenceError } - allow(repository.rugged).to receive(:branches) { [ref] } + branches = double() + allow(branches).to receive(:each) { [ref].each } + allow(repository.rugged).to receive(:branches) { branches } end it 'should return empty branches' do @@ -1289,7 +1304,7 @@ describe Gitlab::Git::Repository, seed_helper: true do describe '#local_branches' do before(:all) do - @repo = Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH) + @repo = Gitlab::Git::Repository.new('default', File.join(TEST_MUTABLE_REPO_PATH, '.git')) end after(:all) do @@ -1304,6 +1319,29 @@ describe Gitlab::Git::Repository, seed_helper: true do expect(@repo.local_branches.any? { |branch| branch.name == 'remote_branch' }).to eq(false) expect(@repo.local_branches.any? { |branch| branch.name == 'local_branch' }).to eq(true) end + + context 'with gitaly enabled' do + before { stub_gitaly } + after { Gitlab::GitalyClient.clear_stubs! } + + it 'gets the branches from GitalyClient' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:local_branches). + and_return([]) + @repo.local_branches + end + + it 'wraps GRPC not found' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:local_branches). + and_raise(GRPC::NotFound) + expect { @repo.local_branches }.to raise_error(Gitlab::Git::Repository::NoRepository) + end + + it 'wraps GRPC exceptions' do + expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:local_branches). + and_raise(GRPC::Unknown) + expect { @repo.local_branches }.to raise_error(Gitlab::Git::CommandError) + end + end end def create_remote_branch(remote_name, branch_name, source_branch_name) diff --git a/spec/lib/gitlab/git/util_spec.rb b/spec/lib/gitlab/git/util_spec.rb index 69d3ca55397..88c871855df 100644 --- a/spec/lib/gitlab/git/util_spec.rb +++ b/spec/lib/gitlab/git/util_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Git::Util do ["", 0], ["foo", 1], ["foo\n", 1], - ["foo\n\n", 2], + ["foo\n\n", 2] ].each do |string, line_count| it "counts #{line_count} lines in #{string.inspect}" do expect(described_class.count_lines(string)).to eq(line_count) diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index d8b72615fab..36d1d777583 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -1,10 +1,13 @@ require 'spec_helper' describe Gitlab::GitAccess, lib: true do - let(:access) { Gitlab::GitAccess.new(actor, project, 'web', authentication_abilities: authentication_abilities) } + let(:pull_access_check) { access.check('git-upload-pack', '_any') } + let(:push_access_check) { access.check('git-receive-pack', '_any') } + let(:access) { Gitlab::GitAccess.new(actor, project, protocol, authentication_abilities: authentication_abilities) } let(:project) { create(:project, :repository) } let(:user) { create(:user) } let(:actor) { user } + let(:protocol) { 'ssh' } let(:authentication_abilities) do [ :read_project, @@ -15,49 +18,188 @@ describe Gitlab::GitAccess, lib: true do describe '#check with single protocols allowed' do def disable_protocol(protocol) - settings = ::ApplicationSetting.create_from_defaults - settings.update_attribute(:enabled_git_access_protocol, protocol) + allow(Gitlab::ProtocolAccess).to receive(:allowed?).with(protocol).and_return(false) end context 'ssh disabled' do before do disable_protocol('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', '_any').allowed?).to be_falsey + expect { push_access_check }.to raise_unauthorized('Git access over SSH is not allowed') end it 'blocks ssh git pull' do - expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey + expect { pull_access_check }.to raise_unauthorized('Git access over SSH is not allowed') end end context 'http disabled' do + let(:protocol) { 'http' } + before do disable_protocol('http') - @acc = Gitlab::GitAccess.new(actor, project, 'http', authentication_abilities: authentication_abilities) end it 'blocks http push' do - expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey + expect { push_access_check }.to raise_unauthorized('Git access over HTTP is not allowed') end it 'blocks http git pull' do - expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey + expect { pull_access_check }.to raise_unauthorized('Git access over HTTP is not allowed') end end end - describe '#check_download_access!' do - subject { access.check('git-upload-pack', '_any') } + describe '#check_project_accessibility!' do + context 'when the project exists' do + context 'when actor exists' do + context 'when actor is a DeployKey' do + let(:deploy_key) { create(:deploy_key, user: user, can_push: true) } + let(:actor) { deploy_key } + + context 'when the DeployKey has access to the project' do + before { deploy_key.projects << project } + + it 'allows pull access' do + expect { pull_access_check }.not_to raise_error + end + + it 'allows push access' do + expect { push_access_check }.not_to raise_error + end + end + + context 'when the Deploykey does not have access to the project' do + it 'blocks pulls with "not found"' do + expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + + it 'blocks pushes with "not found"' do + expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + end + end + context 'when actor is a User' do + context 'when the User can read the project' do + before { project.team << [user, :master] } + + it 'allows pull access' do + expect { pull_access_check }.not_to raise_error + end + + it 'allows push access' do + expect { push_access_check }.not_to raise_error + end + end + + context 'when the User cannot read the project' do + it 'blocks pulls with "not found"' do + expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + + it 'blocks pushes with "not found"' do + expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + end + end + + # For backwards compatibility + context 'when actor is :ci' do + let(:actor) { :ci } + let(:authentication_abilities) { build_authentication_abilities } + + it 'allows pull access' do + expect { pull_access_check }.not_to raise_error + end + + it 'does not block pushes with "not found"' do + expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') + end + end + end + + context 'when actor is nil' do + let(:actor) { nil } + + context 'when guests can read the project' do + let(:project) { create(:project, :repository, :public) } + + it 'allows pull access' do + expect { pull_access_check }.not_to raise_error + end + + it 'does not block pushes with "not found"' do + expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') + end + end + + context 'when guests cannot read the project' do + it 'blocks pulls with "not found"' do + expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + + it 'blocks pushes with "not found"' do + expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + end + end + end + + context 'when the project is nil' do + let(:project) { nil } + + it 'blocks any command with "not found"' do + expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') + expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + end + end + + describe '#check_command_disabled!' do + before { project.team << [user, :master] } + + context 'over http' do + let(:protocol) { 'http' } + + context 'when the git-upload-pack command is disabled in config' do + before do + allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false) + end + + context 'when calling git-upload-pack' do + it { expect { pull_access_check }.to raise_unauthorized('Pulling over HTTP is not allowed.') } + end + + context 'when calling git-receive-pack' do + it { expect { push_access_check }.not_to raise_error } + end + end + + context 'when the git-receive-pack command is disabled in config' do + before do + allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false) + end + + context 'when calling git-receive-pack' do + it { expect { push_access_check }.to raise_unauthorized('Pushing over HTTP is not allowed.') } + end + + context 'when calling git-upload-pack' do + it { expect { pull_access_check }.not_to raise_error } + end + end + end + end + + describe '#check_download_access!' do describe 'master permissions' do before { project.team << [user, :master] } context 'pull code' do - it { expect(subject.allowed?).to be_truthy } + it { expect { pull_access_check }.not_to raise_error } end end @@ -65,8 +207,7 @@ describe Gitlab::GitAccess, lib: true do before { project.team << [user, :guest] } context 'pull code' do - it { expect(subject.allowed?).to be_falsey } - it { expect(subject.message).to match(/You are not allowed to download code/) } + it { expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') } end end @@ -77,24 +218,22 @@ describe Gitlab::GitAccess, lib: true do end context 'pull code' do - it { expect(subject.allowed?).to be_falsey } - it { expect(subject.message).to match(/Your account has been blocked/) } + it { expect { pull_access_check }.to raise_unauthorized('Your account has been blocked.') } end end describe 'without access to project' do context 'pull code' do - it { expect(subject.allowed?).to be_falsey } + it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') } end context 'when project is public' do let(:public_project) { create(:project, :public, :repository) } - let(:guest_access) { Gitlab::GitAccess.new(nil, public_project, 'web', authentication_abilities: []) } - subject { guest_access.check('git-upload-pack', '_any') } + let(:access) { Gitlab::GitAccess.new(nil, public_project, 'web', authentication_abilities: []) } context 'when repository is enabled' do it 'give access to download code' do - expect(subject.allowed?).to be_truthy + expect { pull_access_check }.not_to raise_error end end @@ -102,8 +241,7 @@ describe Gitlab::GitAccess, lib: true do it 'does not give access to download code' do public_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED) - expect(subject.allowed?).to be_falsey - expect(subject.message).to match(/You are not allowed to download code/) + expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') end end end @@ -117,26 +255,26 @@ describe Gitlab::GitAccess, lib: true do context 'when project is authorized' do before { key.projects << project } - it { expect(subject).to be_allowed } + it { expect { pull_access_check }.not_to raise_error } end context 'when unauthorized' do context 'from public project' do let(:project) { create(:project, :public, :repository) } - it { expect(subject).to be_allowed } + it { expect { pull_access_check }.not_to raise_error } end context 'from internal project' do let(:project) { create(:project, :internal, :repository) } - it { expect(subject).not_to be_allowed } + it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') } end context 'from private project' do let(:project) { create(:project, :private, :repository) } - it { expect(subject).not_to be_allowed } + it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') } end end end @@ -149,7 +287,7 @@ describe Gitlab::GitAccess, lib: true do let(:project) { create(:project, :repository, namespace: user.namespace) } context 'pull code' do - it { expect(subject).to be_allowed } + it { expect { pull_access_check }.not_to raise_error } end end @@ -157,7 +295,7 @@ describe Gitlab::GitAccess, lib: true do before { project.team << [user, :reporter] } context 'pull code' do - it { expect(subject).to be_allowed } + it { expect { pull_access_check }.not_to raise_error } end end @@ -168,16 +306,24 @@ describe Gitlab::GitAccess, lib: true do before { project.team << [user, :reporter] } context 'pull code' do - it { expect(subject).to be_allowed } + it { expect { pull_access_check }.not_to raise_error } end end context 'when is not member of the project' do context 'pull code' do - it { expect(subject).not_to be_allowed } + it { expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') } end end end + + describe 'generic CI (build without a user)' do + let(:actor) { :ci } + + context 'pull code' do + it { expect { pull_access_check }.not_to raise_error } + end + end end end @@ -365,42 +511,32 @@ describe Gitlab::GitAccess, lib: true do end end - shared_examples 'pushing code' do |can| - subject { access.check('git-receive-pack', '_any') } + describe 'build authentication abilities' do + let(:authentication_abilities) { build_authentication_abilities } context 'when project is authorized' do - before { authorize } + before { project.team << [user, :reporter] } - it { expect(subject).public_send(can, be_allowed) } + it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') } end context 'when unauthorized' do context 'to public project' do let(:project) { create(:project, :public, :repository) } - it { expect(subject).not_to be_allowed } + it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') } end context 'to internal project' do let(:project) { create(:project, :internal, :repository) } - it { expect(subject).not_to be_allowed } + it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') } end context 'to private project' do let(:project) { create(:project, :private, :repository) } - it { expect(subject).not_to be_allowed } - end - end - end - - describe 'build authentication abilities' do - let(:authentication_abilities) { build_authentication_abilities } - - it_behaves_like 'pushing code', :not_to do - def authorize - project.team << [user, :reporter] + it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') } end end end @@ -412,9 +548,29 @@ describe Gitlab::GitAccess, lib: true do context 'when deploy_key can push' do let(:can_push) { true } - it_behaves_like 'pushing code', :to do - def authorize - key.projects << project + context 'when project is authorized' do + before { key.projects << project } + + it { expect { push_access_check }.not_to raise_error } + end + + context 'when unauthorized' do + context 'to public project' do + let(:project) { create(:project, :public, :repository) } + + it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') } + end + + context 'to internal project' do + let(:project) { create(:project, :internal, :repository) } + + it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') } + end + + context 'to private project' do + let(:project) { create(:project, :private, :repository) } + + it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') } end end end @@ -422,9 +578,29 @@ describe Gitlab::GitAccess, lib: true do context 'when deploy_key cannot push' do let(:can_push) { false } - it_behaves_like 'pushing code', :not_to do - def authorize - key.projects << project + context 'when project is authorized' do + before { key.projects << project } + + it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') } + end + + context 'when unauthorized' do + context 'to public project' do + let(:project) { create(:project, :public, :repository) } + + it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') } + end + + context 'to internal project' do + let(:project) { create(:project, :internal, :repository) } + + it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') } + end + + context 'to private project' do + let(:project) { create(:project, :private, :repository) } + + it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') } end end end @@ -432,6 +608,14 @@ describe Gitlab::GitAccess, lib: true do private + def raise_unauthorized(message) + raise_error(Gitlab::GitAccess::UnauthorizedError, message) + end + + def raise_not_found(message) + raise_error(Gitlab::GitAccess::NotFoundError, message) + end + def build_authentication_abilities [ :read_project, diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb index 1ae293416e4..a1eb95750ba 100644 --- a/spec/lib/gitlab/git_access_wiki_spec.rb +++ b/spec/lib/gitlab/git_access_wiki_spec.rb @@ -20,7 +20,7 @@ describe Gitlab::GitAccessWiki, lib: true do subject { access.check('git-receive-pack', changes) } - it { expect(subject.allowed?).to be_truthy } + it { expect { subject }.not_to raise_error } end def changes @@ -36,7 +36,7 @@ describe Gitlab::GitAccessWiki, lib: true do context 'when wiki feature is enabled' do it 'give access to download wiki code' do - expect(subject.allowed?).to be_truthy + expect { subject }.not_to raise_error end end @@ -44,8 +44,7 @@ describe Gitlab::GitAccessWiki, lib: true do it 'does not give access to download wiki code' do project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED) - expect(subject.allowed?).to be_falsey - expect(subject.message).to match(/You are not allowed to download code/) + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to download code from this project.') end end end diff --git a/spec/lib/gitlab/gitaly_client/commit_spec.rb b/spec/lib/gitlab/gitaly_client/commit_spec.rb index abe08ccdfa1..cf1bc74779e 100644 --- a/spec/lib/gitlab/gitaly_client/commit_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_spec.rb @@ -1,23 +1,24 @@ require 'spec_helper' describe Gitlab::GitalyClient::Commit do - describe '.diff_from_parent' do - let(:diff_stub) { double('Gitaly::Diff::Stub') } - let(:project) { create(:project, :repository) } - let(:repository_message) { project.repository.gitaly_repository } - let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') } + let(:diff_stub) { double('Gitaly::Diff::Stub') } + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:repository_message) { repository.gitaly_repository } + let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') } + describe '#diff_from_parent' do context 'when a commit has a parent' do it 'sends an RPC request with the parent ID as left commit' do request = Gitaly::CommitDiffRequest.new( repository: repository_message, left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', - right_commit_id: commit.id, + right_commit_id: commit.id ) expect_any_instance_of(Gitaly::Diff::Stub).to receive(:commit_diff).with(request) - described_class.diff_from_parent(commit) + described_class.new(repository).diff_from_parent(commit) end end @@ -27,17 +28,17 @@ describe Gitlab::GitalyClient::Commit do request = Gitaly::CommitDiffRequest.new( repository: repository_message, left_commit_id: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', - right_commit_id: initial_commit.id, + right_commit_id: initial_commit.id ) expect_any_instance_of(Gitaly::Diff::Stub).to receive(:commit_diff).with(request) - described_class.diff_from_parent(initial_commit) + described_class.new(repository).diff_from_parent(initial_commit) end end it 'returns a Gitlab::Git::DiffCollection' do - ret = described_class.diff_from_parent(commit) + ret = described_class.new(repository).diff_from_parent(commit) expect(ret).to be_kind_of(Gitlab::Git::DiffCollection) end @@ -47,7 +48,38 @@ describe Gitlab::GitalyClient::Commit do expect(Gitlab::Git::DiffCollection).to receive(:new).with(kind_of(Enumerable), options) - described_class.diff_from_parent(commit, options) + described_class.new(repository).diff_from_parent(commit, options) + end + end + + describe '#commit_deltas' do + context 'when a commit has a parent' do + it 'sends an RPC request with the parent ID as left commit' do + request = Gitaly::CommitDeltaRequest.new( + repository: repository_message, + left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', + right_commit_id: commit.id + ) + + expect_any_instance_of(Gitaly::Diff::Stub).to receive(:commit_delta).with(request).and_return([]) + + described_class.new(repository).commit_deltas(commit) + end + end + + context 'when a commit does not have a parent' do + it 'sends an RPC request with empty tree ref as left commit' do + initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') + request = Gitaly::CommitDeltaRequest.new( + repository: repository_message, + left_commit_id: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', + right_commit_id: initial_commit.id + ) + + expect_any_instance_of(Gitaly::Diff::Stub).to receive(:commit_delta).with(request).and_return([]) + + described_class.new(repository).commit_deltas(initial_commit) + end end end end diff --git a/spec/lib/gitlab/gitaly_client/diff_spec.rb b/spec/lib/gitlab/gitaly_client/diff_spec.rb new file mode 100644 index 00000000000..2960c9a79ad --- /dev/null +++ b/spec/lib/gitlab/gitaly_client/diff_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe Gitlab::GitalyClient::Diff, lib: true do + let(:diff_fields) do + { + to_path: ".gitmodules", + from_path: ".gitmodules", + old_mode: 0100644, + new_mode: 0100644, + from_id: '357406f3075a57708d0163752905cc1576fceacc', + to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', + patch: 'a' * 100 + } + end + + subject { described_class.new(diff_fields) } + + it { is_expected.to respond_to(:from_path) } + it { is_expected.to respond_to(:to_path) } + it { is_expected.to respond_to(:old_mode) } + it { is_expected.to respond_to(:new_mode) } + it { is_expected.to respond_to(:from_id) } + it { is_expected.to respond_to(:to_id) } + it { is_expected.to respond_to(:patch) } + + describe '#==' do + it { expect(subject).to eq(described_class.new(diff_fields)) } + it { expect(subject).not_to eq(described_class.new(diff_fields.merge(patch: 'a'))) } + end +end diff --git a/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb b/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb new file mode 100644 index 00000000000..07650013052 --- /dev/null +++ b/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe Gitlab::GitalyClient::DiffStitcher, lib: true do + describe 'enumeration' do + it 'combines segregated diff messages together' do + diff_1 = OpenStruct.new( + to_path: ".gitmodules", + from_path: ".gitmodules", + old_mode: 0100644, + new_mode: 0100644, + from_id: '357406f3075a57708d0163752905cc1576fceacc', + to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', + patch: 'a' * 100 + ) + diff_2 = OpenStruct.new( + to_path: ".gitignore", + from_path: ".gitignore", + old_mode: 0100644, + new_mode: 0100644, + from_id: '357406f3075a57708d0163752905cc1576fceacc', + to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', + patch: 'a' * 200 + ) + diff_3 = OpenStruct.new( + to_path: "README", + from_path: "README", + old_mode: 0100644, + new_mode: 0100644, + from_id: '357406f3075a57708d0163752905cc1576fceacc', + to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', + patch: 'a' * 100 + ) + + msg_1 = OpenStruct.new(diff_1.to_h.except(:patch)) + msg_1.raw_patch_data = diff_1.patch + msg_1.end_of_patch = true + + msg_2 = OpenStruct.new(diff_2.to_h.except(:patch)) + msg_2.raw_patch_data = diff_2.patch[0..100] + msg_2.end_of_patch = false + + msg_3 = OpenStruct.new(raw_patch_data: diff_2.patch[101..-1], end_of_patch: true) + + msg_4 = OpenStruct.new(diff_3.to_h.except(:patch)) + msg_4.raw_patch_data = diff_3.patch + msg_4.end_of_patch = true + + diff_msgs = [msg_1, msg_2, msg_3, msg_4] + + expected_diffs = [ + Gitlab::GitalyClient::Diff.new(diff_1.to_h), + Gitlab::GitalyClient::Diff.new(diff_2.to_h), + Gitlab::GitalyClient::Diff.new(diff_3.to_h) + ] + + expect(described_class.new(diff_msgs).to_a).to eq(expected_diffs) + end + end +end diff --git a/spec/lib/gitlab/gitaly_client/ref_spec.rb b/spec/lib/gitlab/gitaly_client/ref_spec.rb index 255f23e6270..d8cd2dcbd2a 100644 --- a/spec/lib/gitlab/gitaly_client/ref_spec.rb +++ b/spec/lib/gitlab/gitaly_client/ref_spec.rb @@ -9,6 +9,13 @@ describe Gitlab::GitalyClient::Ref do allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true) end + after do + # When we say `expect_any_instance_of(Gitaly::Ref::Stub)` a double is created, + # and because GitalyClient shares stubs these will get passed from example to + # example, which will cause an error, so we clean the stubs after each example. + Gitlab::GitalyClient.clear_stubs! + end + describe '#branch_names' do it 'sends a find_all_branch_names message' do expect_any_instance_of(Gitaly::Ref::Stub). @@ -38,4 +45,27 @@ describe Gitlab::GitalyClient::Ref do client.default_branch_name end end + + describe '#local_branches' do + it 'sends a find_local_branches message' do + expect_any_instance_of(Gitaly::Ref::Stub). + to receive(:find_local_branches).with(gitaly_request_with_repo_path(repo_path)). + and_return([]) + + client.local_branches + end + + it 'parses and sends the sort parameter' do + expect_any_instance_of(Gitaly::Ref::Stub). + to receive(:find_local_branches). + with(gitaly_request_with_params(sort_by: :UPDATED_DESC)). + and_return([]) + + client.local_branches(sort_by: 'updated_desc') + end + + it 'raises an argument error if an invalid sort_by parameter is passed' do + expect { client.local_branches(sort_by: 'invalid_sort') }.to raise_error(ArgumentError) + end + end end diff --git a/spec/lib/gitlab/gitaly_client_spec.rb b/spec/lib/gitlab/gitaly_client_spec.rb index 08ee0dff6b2..bc4907dc0d6 100644 --- a/spec/lib/gitlab/gitaly_client_spec.rb +++ b/spec/lib/gitlab/gitaly_client_spec.rb @@ -1,7 +1,15 @@ require 'spec_helper' +<<<<<<< HEAD describe Gitlab::GitalyClient, lib: true do describe '.stub' do +======= +# We stub Gitaly in `spec/support/gitaly.rb` for other tests. We don't want +# those stubs while testing the GitalyClient itself. +describe Gitlab::GitalyClient, lib: true, skip_gitaly_mock: true do + describe '.stub' do + # Notice that this is referring to gRPC "stubs", not rspec stubs +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 before { described_class.clear_stubs! } context 'when passed a UNIX socket address' do @@ -27,9 +35,91 @@ describe Gitlab::GitalyClient, lib: true do }) expect(Gitaly::Commit::Stub).to receive(:new).with(address, any_args) +<<<<<<< HEAD + + described_class.stub(:commit, 'default') +======= described_class.stub(:commit, 'default') end end end + + describe 'feature_enabled?' do + let(:feature_name) { 'my_feature' } + let(:real_feature_name) { "gitaly_#{feature_name}" } + + context 'when Gitaly is disabled' do + before { allow(described_class).to receive(:enabled?).and_return(false) } + + it 'returns false' do + expect(described_class.feature_enabled?(feature_name)).to be(false) + end + end + + context 'when the feature status is DISABLED' do + let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::DISABLED } + + it 'returns false' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false) + end + end + + context 'when the feature_status is OPT_IN' do + let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::OPT_IN } + + context "when the feature flag hasn't been set" do + it 'returns false' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false) + end + end + + context "when the feature flag is set to disable" do + before { Feature.get(real_feature_name).disable } + + it 'returns false' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false) + end + end + + context "when the feature flag is set to enable" do + before { Feature.get(real_feature_name).enable } + + it 'returns true' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true) + end + end + + context "when the feature flag is set to a percentage of time" do + before { Feature.get(real_feature_name).enable_percentage_of_time(70) } + + it 'bases the result on pseudo-random numbers' do + expect(Random).to receive(:rand).and_return(0.3) + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true) + + expect(Random).to receive(:rand).and_return(0.8) + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false) + end + end + end + + context 'when the feature_status is OPT_OUT' do + let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::OPT_OUT } + + context "when the feature flag hasn't been set" do + it 'returns true' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true) + end + end + + context "when the feature flag is set to disable" do + before { Feature.get(real_feature_name).disable } + + it 'returns false' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false) + end +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + end + end + end end diff --git a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb index 45ccd3d6459..61c10d47434 100644 --- a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb +++ b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb @@ -1,6 +1,24 @@ require 'spec_helper' describe Gitlab::HealthChecks::FsShardsCheck do + def command_exists?(command) + _, status = Gitlab::Popen.popen(%W{ #{command} 1 echo }) + status == 0 + rescue Errno::ENOENT + false + end + + def timeout_command + @timeout_command ||= + if command_exists?('timeout') + 'timeout' + elsif command_exists?('gtimeout') + 'gtimeout' + else + '' + end + end + let(:metric_class) { Gitlab::HealthChecks::Metric } let(:result_class) { Gitlab::HealthChecks::Result } let(:repository_storages) { [:default] } @@ -15,6 +33,7 @@ describe Gitlab::HealthChecks::FsShardsCheck do before do allow(described_class).to receive(:repository_storages) { repository_storages } allow(described_class).to receive(:storages_paths) { storages_paths } + stub_const('Gitlab::HealthChecks::FsShardsCheck::TIMEOUT_EXECUTABLE', timeout_command) end after do @@ -78,40 +97,76 @@ describe Gitlab::HealthChecks::FsShardsCheck do }.with_indifferent_access end - it { is_expected.to include(metric_class.new(:filesystem_accessible, 0, shard: :default)) } - it { is_expected.to include(metric_class.new(:filesystem_readable, 0, shard: :default)) } - it { is_expected.to include(metric_class.new(:filesystem_writable, 0, shard: :default)) } + it { is_expected.to all(have_attributes(labels: { shard: :default })) } + + it { is_expected.to include(an_object_having_attributes(name: :filesystem_accessible, value: 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_readable, value: 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_writable, value: 0)) } - it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be >= 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be >= 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_access_latency, value: be >= 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_read_latency, value: be >= 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_write_latency, value: be >= 0)) } end context 'storage points to directory that has both read and write rights' do before do FileUtils.chmod_R(0755, tmp_dir) end + it { is_expected.to all(have_attributes(labels: { shard: :default })) } - it { is_expected.to include(metric_class.new(:filesystem_accessible, 1, shard: :default)) } - it { is_expected.to include(metric_class.new(:filesystem_readable, 1, shard: :default)) } - it { is_expected.to include(metric_class.new(:filesystem_writable, 1, shard: :default)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_accessible, value: 1)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_readable, value: 1)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_writable, value: 1)) } - it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be >= 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be >= 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_access_latency, value: be >= 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_read_latency, value: be >= 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_write_latency, value: be >= 0)) } + end + end + end + + context 'when timeout kills fs checks' do + before do + stub_const('Gitlab::HealthChecks::FsShardsCheck::COMMAND_TIMEOUT', '1') + + allow(described_class).to receive(:exec_with_timeout).and_wrap_original { |m| m.call(%w(sleep 60)) } + FileUtils.chmod_R(0755, tmp_dir) + end + + describe '#readiness' do + subject { described_class.readiness } + + it { is_expected.to include(result_class.new(false, 'cannot stat storage', shard: :default)) } + end + + describe '#metrics' do + subject { described_class.metrics } + + it 'provides metrics' do + expect(subject).to all(have_attributes(labels: { shard: :default })) + + expect(subject).to include(an_object_having_attributes(name: :filesystem_accessible, value: 0)) + expect(subject).to include(an_object_having_attributes(name: :filesystem_readable, value: 0)) + expect(subject).to include(an_object_having_attributes(name: :filesystem_writable, value: 0)) + + expect(subject).to include(an_object_having_attributes(name: :filesystem_access_latency, value: be >= 0)) + expect(subject).to include(an_object_having_attributes(name: :filesystem_read_latency, value: be >= 0)) + expect(subject).to include(an_object_having_attributes(name: :filesystem_write_latency, value: be >= 0)) end end end context 'when popen always finds required binaries' do before do - allow(Gitlab::Popen).to receive(:popen).and_wrap_original do |method, *args, &block| + allow(described_class).to receive(:exec_with_timeout).and_wrap_original do |method, *args, &block| begin method.call(*args, &block) - rescue RuntimeError + rescue RuntimeError, Errno::ENOENT raise 'expected not to happen' end end + + stub_const('Gitlab::HealthChecks::FsShardsCheck::COMMAND_TIMEOUT', '10') end it_behaves_like 'filesystem checks' diff --git a/spec/lib/gitlab/health_checks/prometheus_text_format_spec.rb b/spec/lib/gitlab/health_checks/prometheus_text_format_spec.rb new file mode 100644 index 00000000000..ed757ed60d8 --- /dev/null +++ b/spec/lib/gitlab/health_checks/prometheus_text_format_spec.rb @@ -0,0 +1,41 @@ +describe Gitlab::HealthChecks::PrometheusTextFormat do + let(:metric_class) { Gitlab::HealthChecks::Metric } + subject { described_class.new } + + describe '#marshal' do + let(:sample_metrics) do + [metric_class.new('metric1', 1), + metric_class.new('metric2', 2)] + end + + it 'marshal to text with non repeating type definition' do + expected = <<-EXPECTED.strip_heredoc + # TYPE metric1 gauge + metric1 1 + # TYPE metric2 gauge + metric2 2 + EXPECTED + + expect(subject.marshal(sample_metrics)).to eq(expected) + end + + context 'metrics where name repeats' do + let(:sample_metrics) do + [metric_class.new('metric1', 1), + metric_class.new('metric1', 2), + metric_class.new('metric2', 3)] + end + + it 'marshal to text with non repeating type definition' do + expected = <<-EXPECTED.strip_heredoc + # TYPE metric1 gauge + metric1 1 + metric1 2 + # TYPE metric2 gauge + metric2 3 + EXPECTED + expect(subject.marshal(sample_metrics)).to eq(expected) + end + end + end +end diff --git a/spec/lib/gitlab/highlight_spec.rb b/spec/lib/gitlab/highlight_spec.rb index e49799ad105..a20cef3b000 100644 --- a/spec/lib/gitlab/highlight_spec.rb +++ b/spec/lib/gitlab/highlight_spec.rb @@ -57,4 +57,13 @@ describe Gitlab::Highlight, lib: true do end end end + + describe '#highlight' do + it 'links dependencies via DependencyLinker' do + expect(Gitlab::DependencyLinker).to receive(:link). + with('file.name', 'Contents', anything).and_call_original + + described_class.highlight('file.name', 'Contents') + end + end end diff --git a/spec/lib/gitlab/i18n_spec.rb b/spec/lib/gitlab/i18n_spec.rb index 52f2614d5ca..a3dbeaa3753 100644 --- a/spec/lib/gitlab/i18n_spec.rb +++ b/spec/lib/gitlab/i18n_spec.rb @@ -1,27 +1,27 @@ require 'spec_helper' -module Gitlab - describe I18n, lib: true do - let(:user) { create(:user, preferred_language: 'es') } +describe Gitlab::I18n, lib: true do + let(:user) { create(:user, preferred_language: 'es') } - describe '.set_locale' do - it 'sets the locale based on current user preferred language' do - Gitlab::I18n.set_locale(user) + describe '.locale=' do + after { described_class.use_default_locale } - expect(FastGettext.locale).to eq('es') - expect(::I18n.locale).to eq(:es) - end + it 'sets the locale based on current user preferred language' do + described_class.locale = user.preferred_language + + expect(FastGettext.locale).to eq('es') + expect(::I18n.locale).to eq(:es) end + end - describe '.reset_locale' do - it 'resets the locale to the default language' do - Gitlab::I18n.set_locale(user) + describe '.use_default_locale' do + it 'resets the locale to the default language' do + described_class.locale = user.preferred_language - Gitlab::I18n.reset_locale + described_class.use_default_locale - expect(FastGettext.locale).to eq('en') - expect(::I18n.locale).to eq(:en) - end + expect(FastGettext.locale).to eq('en') + expect(::I18n.locale).to eq(:en) end end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 688e731bf15..412eb33b35b 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -85,11 +85,13 @@ merge_requests: - merge_requests_closing_issues - metrics - timelogs +- head_pipeline merge_request_diff: - merge_request pipelines: - project - user +- stages - statuses - builds - trigger_requests @@ -102,9 +104,16 @@ pipelines: - manual_actions - artifacts - pipeline_schedule +- merge_requests +stages: +- project +- pipeline +- statuses +- builds statuses: - project - pipeline +- stage - user - auto_canceled_by variables: @@ -129,6 +138,7 @@ services: - service_hook hooks: - project +- web_hook_logs protected_branches: - project - merge_access_levels @@ -141,7 +151,9 @@ merge_access_levels: push_access_levels: - protected_branch create_access_levels: +- user - protected_tag +- group container_repositories: - project - name diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index fdbb6a0556d..e3599d6fe59 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -6997,7 +6997,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "TeamcityService", "category": "ci", "default": false, @@ -7041,7 +7041,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "RedmineService", "category": "issue_tracker", "default": false, @@ -7063,7 +7063,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "PushoverService", "category": "common", "default": false, @@ -7085,7 +7085,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "PivotalTrackerService", "category": "common", "default": false, @@ -7108,7 +7108,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "JiraService", "category": "issue_tracker", "default": false, @@ -7130,7 +7130,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "IrkerService", "category": "common", "default": false, @@ -7174,7 +7174,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "GemnasiumService", "category": "common", "default": false, @@ -7196,7 +7196,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "FlowdockService", "category": "common", "default": false, @@ -7218,7 +7218,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "ExternalWikiService", "category": "common", "default": false, @@ -7240,7 +7240,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "EmailsOnPushService", "category": "common", "default": false, @@ -7262,7 +7262,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "DroneCiService", "category": "ci", "default": false, @@ -7284,7 +7284,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "CustomIssueTrackerService", "category": "issue_tracker", "default": false, @@ -7306,7 +7306,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "CampfireService", "category": "common", "default": false, @@ -7328,7 +7328,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "BuildkiteService", "category": "ci", "default": false, @@ -7350,7 +7350,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "BambooService", "category": "ci", "default": false, @@ -7372,7 +7372,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "AssemblaService", "category": "common", "default": false, @@ -7394,7 +7394,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "AssemblaService", "category": "common", "default": false, @@ -7416,7 +7416,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "category": "common", "default": false, "wiki_page_events": true, diff --git a/spec/lib/gitlab/import_export/relation_factory_spec.rb b/spec/lib/gitlab/import_export/relation_factory_spec.rb index 06cd8ab87ed..5417c7534ea 100644 --- a/spec/lib/gitlab/import_export/relation_factory_spec.rb +++ b/spec/lib/gitlab/import_export/relation_factory_spec.rb @@ -33,7 +33,7 @@ describe Gitlab::ImportExport::RelationFactory, lib: true do 'tag_push_events' => false, 'note_events' => true, 'enable_ssl_verification' => true, - 'build_events' => false, + 'job_events' => false, 'wiki_page_events' => true, 'token' => token } @@ -95,7 +95,7 @@ describe Gitlab::ImportExport::RelationFactory, lib: true do 'random_id' => 99, 'milestone_id' => 99, 'project_id' => 99, - 'user_id' => 99, + 'user_id' => 99 } end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 29a9ad453fb..50ff6ecc1e0 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -54,6 +54,7 @@ Note: - type - position - original_position +- change_position - resolved_at - resolved_by_id - discussion_id @@ -91,6 +92,7 @@ Milestone: ProjectSnippet: - id - title +- description - content - author_id - project_id @@ -158,6 +160,7 @@ MergeRequest: - time_estimate - last_edited_at - last_edited_by_id +- head_pipeline_id MergeRequestDiff: - id - state @@ -172,6 +175,7 @@ MergeRequestDiff: Ci::Pipeline: - id - project_id +- source - ref - sha - before_sha @@ -189,6 +193,13 @@ Ci::Pipeline: - lock_version - auto_canceled_by_id - pipeline_schedule_id +Ci::Stage: +- id +- name +- project_id +- pipeline_id +- created_at +- updated_at CommitStatus: - id - project_id @@ -210,6 +221,7 @@ CommitStatus: - stage - trigger_request_id - stage_idx +- stage_id - tag - ref - user_id @@ -291,7 +303,7 @@ Service: - tag_push_events - note_events - pipeline_events -- build_events +- job_events - category - default - wiki_page_events @@ -311,11 +323,12 @@ ProjectHook: - note_events - pipeline_events - enable_ssl_verification -- build_events +- job_events - wiki_page_events - token - group_id - confidential_issues_events +- repository_update_events ProtectedBranch: - id - project_id diff --git a/spec/lib/gitlab/ldap/user_spec.rb b/spec/lib/gitlab/ldap/user_spec.rb index f4aab429931..a0eda685ca3 100644 --- a/spec/lib/gitlab/ldap/user_spec.rb +++ b/spec/lib/gitlab/ldap/user_spec.rb @@ -37,7 +37,7 @@ describe Gitlab::LDAP::User, lib: true do end 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) + create(:omniauth_user, email: 'john@example.com', extern_uid: 'my-uid', provider: 'ldapmain', external_email: true, email_provider: 'ldapmain') expect(ldap_user.changed?).to be_falsey end end @@ -141,8 +141,12 @@ describe Gitlab::LDAP::User, lib: true do expect(ldap_user.gl_user.email).to eq(info[:email]) end - it "has ldap_email set to true" do - expect(ldap_user.gl_user.ldap_email?).to be(true) + it "has external_email set to true" do + expect(ldap_user.gl_user.external_email?).to be(true) + end + + it "has email_provider set to provider" do + expect(ldap_user.gl_user.email_provider).to eql 'ldapmain' end end @@ -155,8 +159,8 @@ describe Gitlab::LDAP::User, lib: true do expect(ldap_user.gl_user.temp_oauth_email?).to be(true) end - it "has ldap_email set to false" do - expect(ldap_user.gl_user.ldap_email?).to be(false) + it "has external_email set to false" do + expect(ldap_user.gl_user.external_email?).to be(false) end end end diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb index 208a8d028cd..5a87b906609 100644 --- a/spec/lib/gitlab/metrics_spec.rb +++ b/spec/lib/gitlab/metrics_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Gitlab::Metrics do + include StubENV + describe '.settings' do it 'returns a Hash' do expect(described_class.settings).to be_an_instance_of(Hash) @@ -9,7 +11,19 @@ describe Gitlab::Metrics do describe '.enabled?' do it 'returns a boolean' do - expect([true, false].include?(described_class.enabled?)).to eq(true) + expect(described_class.enabled?).to be_in([true, false]) + end + end + + describe '.prometheus_metrics_enabled?' do + it 'returns a boolean' do + expect(described_class.prometheus_metrics_enabled?).to be_in([true, false]) + end + end + + describe '.influx_metrics_enabled?' do + it 'returns a boolean' do + expect(described_class.influx_metrics_enabled?).to be_in([true, false]) end end @@ -177,4 +191,133 @@ describe Gitlab::Metrics do end end end + + shared_examples 'prometheus metrics API' do + describe '#counter' do + subject { described_class.counter(:couter, 'doc') } + + describe '#increment' do + it 'successfully calls #increment without arguments' do + expect { subject.increment }.not_to raise_exception + end + + it 'successfully calls #increment with 1 argument' do + expect { subject.increment({}) }.not_to raise_exception + end + + it 'successfully calls #increment with 2 arguments' do + expect { subject.increment({}, 1) }.not_to raise_exception + end + end + end + + describe '#summary' do + subject { described_class.summary(:summary, 'doc') } + + describe '#observe' do + it 'successfully calls #observe with 2 arguments' do + expect { subject.observe({}, 2) }.not_to raise_exception + end + end + end + + describe '#gauge' do + subject { described_class.gauge(:gauge, 'doc') } + + describe '#set' do + it 'successfully calls #set with 2 arguments' do + expect { subject.set({}, 1) }.not_to raise_exception + end + end + end + + describe '#histogram' do + subject { described_class.histogram(:histogram, 'doc') } + + describe '#observe' do + it 'successfully calls #observe with 2 arguments' do + expect { subject.observe({}, 2) }.not_to raise_exception + end + end + end + end + + context 'prometheus metrics disabled' do + before do + allow(described_class).to receive(:prometheus_metrics_enabled?).and_return(false) + end + + it_behaves_like 'prometheus metrics API' + + describe '#null_metric' do + subject { described_class.provide_metric(:test) } + + it { is_expected.to be_a(Gitlab::Metrics::NullMetric) } + end + + describe '#counter' do + subject { described_class.counter(:counter, 'doc') } + + it { is_expected.to be_a(Gitlab::Metrics::NullMetric) } + end + + describe '#summary' do + subject { described_class.summary(:summary, 'doc') } + + it { is_expected.to be_a(Gitlab::Metrics::NullMetric) } + end + + describe '#gauge' do + subject { described_class.gauge(:gauge, 'doc') } + + it { is_expected.to be_a(Gitlab::Metrics::NullMetric) } + end + + describe '#histogram' do + subject { described_class.histogram(:histogram, 'doc') } + + it { is_expected.to be_a(Gitlab::Metrics::NullMetric) } + end + end + + context 'prometheus metrics enabled' do + let(:metrics_multiproc_dir) { Dir.mktmpdir } + + before do + stub_const('Prometheus::Client::Multiprocdir', metrics_multiproc_dir) + allow(described_class).to receive(:prometheus_metrics_enabled?).and_return(true) + end + + it_behaves_like 'prometheus metrics API' + + describe '#null_metric' do + subject { described_class.provide_metric(:test) } + + it { is_expected.to be_nil } + end + + describe '#counter' do + subject { described_class.counter(:name, 'doc') } + + it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) } + end + + describe '#summary' do + subject { described_class.summary(:name, 'doc') } + + it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) } + end + + describe '#gauge' do + subject { described_class.gauge(:name, 'doc') } + + it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) } + end + + describe '#histogram' do + subject { described_class.histogram(:name, 'doc') } + + it { is_expected.not_to be_a(Gitlab::Metrics::NullMetric) } + end + end end diff --git a/spec/lib/gitlab/o_auth/provider_spec.rb b/spec/lib/gitlab/o_auth/provider_spec.rb new file mode 100644 index 00000000000..1e2a1f8c039 --- /dev/null +++ b/spec/lib/gitlab/o_auth/provider_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Gitlab::OAuth::Provider, lib: true do + describe '#config_for' do + context 'for an LDAP provider' do + context 'when the provider exists' do + it 'returns the config' do + expect(described_class.config_for('ldapmain')).to be_a(Hash) + end + end + + context 'when the provider does not exist' do + it 'returns nil' do + expect(described_class.config_for('ldapfoo')).to be_nil + end + end + end + + context 'for an OmniAuth provider' do + before do + provider = OpenStruct.new( + name: 'google', + app_id: 'asd123', + app_secret: 'asd123' + ) + allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider]) + end + + context 'when the provider exists' do + it 'returns the config' do + expect(described_class.config_for('google')).to be_a(OpenStruct) + end + end + + context 'when the provider does not exist' do + it 'returns nil' do + expect(described_class.config_for('foo')).to be_nil + end + end + end + end +end diff --git a/spec/lib/gitlab/o_auth/user_spec.rb b/spec/lib/gitlab/o_auth/user_spec.rb index 828c953197d..8943d1aa488 100644 --- a/spec/lib/gitlab/o_auth/user_spec.rb +++ b/spec/lib/gitlab/o_auth/user_spec.rb @@ -28,11 +28,11 @@ describe Gitlab::OAuth::User, lib: true do end end - describe '#save' do - def stub_omniauth_config(messages) - allow(Gitlab.config.omniauth).to receive_messages(messages) - end + def stub_omniauth_config(messages) + allow(Gitlab.config.omniauth).to receive_messages(messages) + end + describe '#save' do def stub_ldap_config(messages) allow(Gitlab::LDAP::Config).to receive_messages(messages) end @@ -377,4 +377,40 @@ describe Gitlab::OAuth::User, lib: true do end end end + + describe 'updating email' do + let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') } + + before do + stub_omniauth_config(sync_email_from_provider: 'my-provider') + end + + context "when provider sets an email" do + it "updates the user email" do + expect(gl_user.email).to eq(info_hash[:email]) + end + + it "has external_email set to true" do + expect(gl_user.external_email?).to be(true) + end + + it "has email_provider set to provider" do + expect(gl_user.email_provider).to eql 'my-provider' + end + end + + context "when provider doesn't set an email" do + before do + info_hash.delete(:email) + end + + it "does not update the user email" do + expect(gl_user.email).not_to eq(info_hash[:email]) + end + + it "has external_email set to false" do + expect(gl_user.external_email?).to be(false) + end + end + end end diff --git a/spec/lib/gitlab/otp_key_rotator_spec.rb b/spec/lib/gitlab/otp_key_rotator_spec.rb new file mode 100644 index 00000000000..6e6e9ce29ac --- /dev/null +++ b/spec/lib/gitlab/otp_key_rotator_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe Gitlab::OtpKeyRotator do + let(:file) { Tempfile.new("otp-key-rotator-test") } + let(:filename) { file.path } + let(:old_key) { Gitlab::Application.secrets.otp_key_base } + let(:new_key) { "00" * 32 } + let!(:users) { create_list(:user, 5, :two_factor) } + + after do + file.close + file.unlink + end + + def data + CSV.read(filename) + end + + def build_row(user, applied = false) + [user.id.to_s, encrypt_otp(user, old_key), encrypt_otp(user, new_key)] + end + + def encrypt_otp(user, key) + opts = { + value: user.otp_secret, + iv: user.encrypted_otp_secret_iv.unpack("m").join, + salt: user.encrypted_otp_secret_salt.unpack("m").join, + algorithm: 'aes-256-cbc', + insecure_mode: true, + key: key + } + [Encryptor.encrypt(opts)].pack("m") + end + + subject(:rotator) { described_class.new(filename) } + + describe '#rotate!' do + subject(:rotation) { rotator.rotate!(old_key: old_key, new_key: new_key) } + + it 'stores the calculated values in a spreadsheet' do + rotation + + expect(data).to match_array(users.map {|u| build_row(u) }) + end + + context 'new key is too short' do + let(:new_key) { "00" * 31 } + + it { expect { rotation }.to raise_error(ArgumentError) } + end + + context 'new key is the same as the old key' do + let(:new_key) { old_key } + + it { expect { rotation }.to raise_error(ArgumentError) } + end + end + + describe '#rollback!' do + it 'updates rows to the old value' do + file.puts("#{users[0].id},old,new") + file.close + + rotator.rollback! + + expect(users[0].reload.encrypted_otp_secret).to eq('old') + expect(users[1].reload.encrypted_otp_secret).not_to eq('old') + end + end +end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb new file mode 100644 index 00000000000..1eea710c80b --- /dev/null +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -0,0 +1,384 @@ +# coding: utf-8 +require 'spec_helper' + +describe Gitlab::PathRegex, lib: true do + # Pass in a full path to remove the format segment: + # `/ci/lint(.:format)` -> `/ci/lint` + def without_format(path) + path.split('(', 2)[0] + end + + # Pass in a full path and get the last segment before a wildcard + # That's not a parameter + # `/*namespace_id/:project_id/builds/artifacts/*ref_name_and_path` + # -> 'builds/artifacts' + def path_before_wildcard(path) + path = path.gsub(STARTING_WITH_NAMESPACE, "") + path_segments = path.split('/').reject(&:empty?) + wildcard_index = path_segments.index { |segment| parameter?(segment) } + + segments_before_wildcard = path_segments[0..wildcard_index - 1] + + segments_before_wildcard.join('/') + end + + def parameter?(segment) + segment =~ /[*:]/ + end + + # If the path is reserved. Then no conflicting paths can# be created for any + # route using this reserved word. + # + # Both `builds/artifacts` & `build` are covered by reserving the word + # `build` + def wildcards_include?(path) + described_class::PROJECT_WILDCARD_ROUTES.include?(path) || + described_class::PROJECT_WILDCARD_ROUTES.include?(path.split('/').first) + end + + def failure_message(missing_words, constant_name, migration_helper) + missing_words = Array(missing_words) + <<-MSG + Found new routes that could cause conflicts with existing namespaced routes + for groups or projects. + + Add <#{missing_words.join(', ')}> to `Gitlab::PathRegex::#{constant_name} + to make sure no projects or namespaces can be created with those paths. + + To rename any existing records with those paths you can use the + `Gitlab::Database::RenameReservedpathsMigration::<VERSION>.#{migration_helper}` + migration helper. + + Make sure to make a note of the renamed records in the release blog post. + + MSG + end + + let(:all_routes) do + route_set = Rails.application.routes + routes_collection = route_set.routes + routes_array = routes_collection.routes + routes_array.map { |route| route.path.spec.to_s } + end + + let(:routes_without_format) { all_routes.map { |path| without_format(path) } } + + # Routes not starting with `/:` or `/*` + # all routes not starting with a param + let(:routes_not_starting_in_wildcard) { routes_without_format.select { |p| p !~ %r{^/[:*]} } } + + let(:top_level_words) do + routes_not_starting_in_wildcard.map do |route| + route.split('/')[1] + end.compact.uniq + end + + # All routes that start with a namespaced path, that have 1 or more + # path-segments before having another wildcard parameter. + # - Starting with paths: + # - `/*namespace_id/:project_id/` + # - `/*namespace_id/:id/` + # - Followed by one or more path-parts not starting with `:` or `*` + # - Followed by a path-part that includes a wildcard parameter `*` + # At the time of writing these routes match: http://rubular.com/r/Rv2pDE5Dvw + STARTING_WITH_NAMESPACE = %r{^/\*namespace_id/:(project_)?id} + NON_PARAM_PARTS = %r{[^:*][a-z\-_/]*} + ANY_OTHER_PATH_PART = %r{[a-z\-_/:]*} + WILDCARD_SEGMENT = %r{\*} + let(:namespaced_wildcard_routes) do + routes_without_format.select do |p| + p =~ %r{#{STARTING_WITH_NAMESPACE}/#{NON_PARAM_PARTS}/#{ANY_OTHER_PATH_PART}#{WILDCARD_SEGMENT}} + end + end + + # This will return all paths that are used in a namespaced route + # before another wildcard path: + # + # /*namespace_id/:project_id/builds/artifacts/*ref_name_and_path + # /*namespace_id/:project_id/info/lfs/objects/*oid + # /*namespace_id/:project_id/commits/*id + # /*namespace_id/:project_id/builds/:build_id/artifacts/file/*path + # -> ['builds/artifacts', 'info/lfs/objects', 'commits', 'artifacts/file'] + let(:all_wildcard_paths) do + namespaced_wildcard_routes.map do |route| + path_before_wildcard(route) + end.uniq + end + + STARTING_WITH_GROUP = %r{^/groups/\*(group_)?id/} + let(:group_routes) do + routes_without_format.select do |path| + path =~ STARTING_WITH_GROUP + end + end + + let(:paths_after_group_id) do + group_routes.map do |route| + route.gsub(STARTING_WITH_GROUP, '').split('/').first + end.uniq + end + + describe 'TOP_LEVEL_ROUTES' do + it 'includes all the top level namespaces' do + failure_block = lambda do + missing_words = top_level_words - described_class::TOP_LEVEL_ROUTES + failure_message(missing_words, 'TOP_LEVEL_ROUTES', 'rename_root_paths') + end + + expect(described_class::TOP_LEVEL_ROUTES) + .to include(*top_level_words), failure_block + end + end + + describe 'GROUP_ROUTES' do + it "don't contain a second wildcard" do + failure_block = lambda do + missing_words = paths_after_group_id - described_class::GROUP_ROUTES + failure_message(missing_words, 'GROUP_ROUTES', 'rename_child_paths') + end + + expect(described_class::GROUP_ROUTES) + .to include(*paths_after_group_id), failure_block + end + end + + describe 'PROJECT_WILDCARD_ROUTES' do + it 'includes all paths that can be used after a namespace/project path' do + aggregate_failures do + all_wildcard_paths.each do |path| + expect(wildcards_include?(path)) + .to be(true), failure_message(path, 'PROJECT_WILDCARD_ROUTES', 'rename_wildcard_paths') + end + end + end + end + + describe '.root_namespace_path_regex' do + subject { described_class.root_namespace_path_regex } + + it 'rejects top level routes' do + expect(subject).not_to match('admin/') + expect(subject).not_to match('api/') + expect(subject).not_to match('.well-known/') + end + + it 'accepts project wildcard routes' do + expect(subject).to match('blob/') + expect(subject).to match('edit/') + expect(subject).to match('wikis/') + end + + it 'accepts group routes' do + expect(subject).to match('activity/') + expect(subject).to match('group_members/') + expect(subject).to match('subgroups/') + end + + it 'is not case sensitive' do + expect(subject).not_to match('Users/') + end + + it 'does not allow extra slashes' do + expect(subject).not_to match('/blob/') + expect(subject).not_to match('blob//') + end + end + + describe '.full_namespace_path_regex' do + subject { described_class.full_namespace_path_regex } + + context 'at the top level' do + context 'when the final level' do + it 'rejects top level routes' do + expect(subject).not_to match('admin/') + expect(subject).not_to match('api/') + expect(subject).not_to match('.well-known/') + end + + it 'accepts project wildcard routes' do + expect(subject).to match('blob/') + expect(subject).to match('edit/') + expect(subject).to match('wikis/') + end + + it 'accepts group routes' do + expect(subject).to match('activity/') + expect(subject).to match('group_members/') + expect(subject).to match('subgroups/') + end + end + + context 'when more levels follow' do + it 'rejects top level routes' do + expect(subject).not_to match('admin/more/') + expect(subject).not_to match('api/more/') + expect(subject).not_to match('.well-known/more/') + end + + it 'accepts project wildcard routes' do + expect(subject).to match('blob/more/') + expect(subject).to match('edit/more/') + expect(subject).to match('wikis/more/') + expect(subject).to match('environments/folders/') + expect(subject).to match('info/lfs/objects/') + end + + it 'accepts group routes' do + expect(subject).to match('activity/more/') + expect(subject).to match('group_members/more/') + expect(subject).to match('subgroups/more/') + end + end + end + + context 'at the second level' do + context 'when the final level' do + it 'accepts top level routes' do + expect(subject).to match('root/admin/') + expect(subject).to match('root/api/') + expect(subject).to match('root/.well-known/') + end + + it 'rejects project wildcard routes' do + expect(subject).not_to match('root/blob/') + expect(subject).not_to match('root/edit/') + expect(subject).not_to match('root/wikis/') + expect(subject).not_to match('root/environments/folders/') + expect(subject).not_to match('root/info/lfs/objects/') + end + + it 'rejects group routes' do + expect(subject).not_to match('root/activity/') + expect(subject).not_to match('root/group_members/') + expect(subject).not_to match('root/subgroups/') + end + end + + context 'when more levels follow' do + it 'accepts top level routes' do + expect(subject).to match('root/admin/more/') + expect(subject).to match('root/api/more/') + expect(subject).to match('root/.well-known/more/') + end + + it 'rejects project wildcard routes' do + expect(subject).not_to match('root/blob/more/') + expect(subject).not_to match('root/edit/more/') + expect(subject).not_to match('root/wikis/more/') + expect(subject).not_to match('root/environments/folders/more/') + expect(subject).not_to match('root/info/lfs/objects/more/') + end + + it 'rejects group routes' do + expect(subject).not_to match('root/activity/more/') + expect(subject).not_to match('root/group_members/more/') + expect(subject).not_to match('root/subgroups/more/') + end + end + end + + it 'is not case sensitive' do + expect(subject).not_to match('root/Blob/') + end + + it 'does not allow extra slashes' do + expect(subject).not_to match('/root/admin/') + expect(subject).not_to match('root/admin//') + end + end + + describe '.project_path_regex' do + subject { described_class.project_path_regex } + + it 'accepts top level routes' do + expect(subject).to match('admin/') + expect(subject).to match('api/') + expect(subject).to match('.well-known/') + end + + it 'rejects project wildcard routes' do + expect(subject).not_to match('blob/') + expect(subject).not_to match('edit/') + expect(subject).not_to match('wikis/') + expect(subject).not_to match('environments/folders/') + expect(subject).not_to match('info/lfs/objects/') + end + + it 'accepts group routes' do + expect(subject).to match('activity/') + expect(subject).to match('group_members/') + expect(subject).to match('subgroups/') + end + + it 'is not case sensitive' do + expect(subject).not_to match('Blob/') + end + + it 'does not allow extra slashes' do + expect(subject).not_to match('/admin/') + expect(subject).not_to match('admin//') + end + end + + describe '.full_project_path_regex' do + subject { described_class.full_project_path_regex } + + it 'accepts top level routes' do + expect(subject).to match('root/admin/') + expect(subject).to match('root/api/') + expect(subject).to match('root/.well-known/') + end + + it 'rejects project wildcard routes' do + expect(subject).not_to match('root/blob/') + expect(subject).not_to match('root/edit/') + expect(subject).not_to match('root/wikis/') + expect(subject).not_to match('root/environments/folders/') + expect(subject).not_to match('root/info/lfs/objects/') + end + + it 'accepts group routes' do + expect(subject).to match('root/activity/') + expect(subject).to match('root/group_members/') + expect(subject).to match('root/subgroups/') + end + + it 'is not case sensitive' do + expect(subject).not_to match('root/Blob/') + end + + it 'does not allow extra slashes' do + expect(subject).not_to match('/root/admin/') + expect(subject).not_to match('root/admin//') + end + end + + describe '.namespace_format_regex' do + subject { described_class.namespace_format_regex } + + it { is_expected.to match('gitlab-ce') } + it { is_expected.to match('gitlab_git') } + it { is_expected.to match('_underscore.js') } + it { is_expected.to match('100px.com') } + it { is_expected.to match('gitlab.org') } + it { is_expected.not_to match('?gitlab') } + it { is_expected.not_to match('git lab') } + it { is_expected.not_to match('gitlab.git') } + it { is_expected.not_to match('gitlab.org.') } + it { is_expected.not_to match('gitlab.org/') } + it { is_expected.not_to match('/gitlab.org') } + it { is_expected.not_to match('gitlab git') } + end + + describe '.project_path_format_regex' do + subject { described_class.project_path_format_regex } + + it { is_expected.to match('gitlab-ce') } + it { is_expected.to match('gitlab_git') } + it { is_expected.to match('_underscore.js') } + it { is_expected.to match('100px.com') } + it { is_expected.not_to match('?gitlab') } + it { is_expected.not_to match('git lab') } + it { is_expected.not_to match('gitlab.git') } + end +end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 6e0b1192706..b3ce73abd53 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -55,7 +55,7 @@ describe Gitlab::ProjectSearchResults, lib: true do end it 'finds by name' do - expect(results).to include(["files/images/wm.svg", nil]) + expect(results.map(&:first)).to include('files/images/wm.svg') end it 'finds by content' do @@ -123,8 +123,13 @@ describe Gitlab::ProjectSearchResults, lib: true do context 'when wiki is internal' do let(:project) { create(:project, :public, :wiki_private) } +<<<<<<< HEAD it 'finds wiki blobs for members' do project.add_reporter(user) +======= + it 'finds wiki blobs for guest' do + project.add_guest(user) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 is_expected.not_to be_empty end diff --git a/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb new file mode 100644 index 00000000000..d957dd932c4 --- /dev/null +++ b/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Gitlab::Prometheus::Queries::DeploymentQuery, lib: true do + let(:environment) { create(:environment, slug: 'environment-slug') } + let(:deployment) { create(:deployment, environment: environment) } + + let(:client) { double('prometheus_client') } + subject { described_class.new(client) } + + around do |example| + time_without_subsecond_values = Time.local(2008, 9, 1, 12, 0, 0) + Timecop.freeze(time_without_subsecond_values) { example.run } + end + + it 'sends appropriate queries to prometheus' do + start_time = (deployment.created_at - 30.minutes).to_f + stop_time = (deployment.created_at + 30.minutes).to_f + created_at = deployment.created_at.to_f + + expect(client).to receive(:query_range).with('avg(container_memory_usage_bytes{container_name!="POD",environment="environment-slug"}) / 2^20', + start: start_time, stop: stop_time) + expect(client).to receive(:query).with('avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="environment-slug"}[30m]))', + time: created_at) + expect(client).to receive(:query).with('avg(avg_over_time(container_memory_usage_bytes{container_name!="POD",environment="environment-slug"}[30m]))', + time: stop_time) + + expect(client).to receive(:query_range).with('avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="environment-slug"}[2m])) * 100', + start: start_time, stop: stop_time) + expect(client).to receive(:query).with('avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="environment-slug"}[30m])) * 100', + time: created_at) + expect(client).to receive(:query).with('avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="environment-slug"}[30m])) * 100', + time: stop_time) + + expect(subject.query(deployment.id)).to eq(memory_values: nil, memory_before: nil, memory_after: nil, + cpu_values: nil, cpu_before: nil, cpu_after: nil) + end +end diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_client_spec.rb index 9d67e3d2f37..2d8bd2f6b97 100644 --- a/spec/lib/gitlab/prometheus_spec.rb +++ b/spec/lib/gitlab/prometheus_client_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Prometheus, lib: true do +describe Gitlab::PrometheusClient, lib: true do include PrometheusHelpers subject { described_class.new(api_url: 'https://prometheus.example.com') } diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index a7d1283acb8..e34d8a52fc6 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::Regex, lib: true do +<<<<<<< HEAD # Pass in a full path to remove the format segment: # `/ci/lint(.:format)` -> `/ci/lint` def without_format(path) @@ -382,6 +383,8 @@ describe Gitlab::Regex, lib: true do it { is_expected.not_to match('gitlab.git') } end +======= +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 describe '.project_name_regex' do subject { described_class.project_name_regex } @@ -412,16 +415,4 @@ describe Gitlab::Regex, lib: true do it { is_expected.not_to match('9foo') } it { is_expected.not_to match('foo-') } end - - describe '.full_namespace_regex' do - subject { described_class.full_namespace_regex } - - it { is_expected.to match('gitlab.org') } - it { is_expected.to match('gitlab.org/gitlab-git') } - it { is_expected.not_to match('gitlab.org.') } - it { is_expected.not_to match('gitlab.org/') } - it { is_expected.not_to match('/gitlab.org') } - it { is_expected.not_to match('gitlab.git') } - it { is_expected.not_to match('gitlab git') } - end end diff --git a/spec/lib/gitlab/repo_path_spec.rb b/spec/lib/gitlab/repo_path_spec.rb index f94c9c2e315..f9025397107 100644 --- a/spec/lib/gitlab/repo_path_spec.rb +++ b/spec/lib/gitlab/repo_path_spec.rb @@ -29,7 +29,7 @@ describe ::Gitlab::RepoPath do before do allow(Gitlab.config.repositories).to receive(:storages).and_return({ 'storage1' => { 'path' => '/foo' }, - 'storage2' => { 'path' => '/bar' }, + 'storage2' => { 'path' => '/bar' } }) end diff --git a/spec/lib/gitlab/string_range_marker_spec.rb b/spec/lib/gitlab/string_range_marker_spec.rb new file mode 100644 index 00000000000..7c77772b3f6 --- /dev/null +++ b/spec/lib/gitlab/string_range_marker_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Gitlab::StringRangeMarker, lib: true do + describe '#mark' do + context "when the rich text is html safe" do + let(:raw) { "abc <def>" } + let(:rich) { %{<span class="abc">abc</span><span class="space"> </span><span class="def"><def></span>}.html_safe } + let(:inline_diffs) { [2..5] } + subject do + described_class.new(raw, rich).mark(inline_diffs) do |text, left:, right:| + "LEFT#{text}RIGHT" + end + end + + it 'marks the inline diffs' do + expect(subject).to eq(%{<span class="abc">abLEFTcRIGHT</span><span class="space">LEFT RIGHT</span><span class="def">LEFT<dRIGHTef></span>}) + expect(subject).to be_html_safe + end + end + + context "when the rich text is not html safe" do + let(:raw) { "abc <def>" } + let(:inline_diffs) { [2..5] } + subject do + described_class.new(raw).mark(inline_diffs) do |text, left:, right:| + "LEFT#{text}RIGHT" + end + end + + it 'marks the inline diffs' do + expect(subject).to eq(%{abLEFTc <dRIGHTef>}) + expect(subject).to be_html_safe + end + end + end +end diff --git a/spec/lib/gitlab/string_regex_marker_spec.rb b/spec/lib/gitlab/string_regex_marker_spec.rb new file mode 100644 index 00000000000..2f5cf6c6e3b --- /dev/null +++ b/spec/lib/gitlab/string_regex_marker_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe Gitlab::StringRegexMarker, lib: true do + describe '#mark' do + let(:raw) { %{"name": "AFNetworking"} } + let(:rich) { %{<span class="key">"name"</span><span class="punctuation">: </span><span class="value">"AFNetworking"</span>}.html_safe } + subject do + described_class.new(raw, rich).mark(/"[^"]+":\s*"(?<name>[^"]+)"/, group: :name) do |text, left:, right:| + %{<a href="#">#{text}</a>} + end + end + + it 'marks the inline diffs' do + expect(subject).to eq(%{<span class="key">"name"</span><span class="punctuation">: </span><span class="value">"<a href="#">AFNetworking</a>"</span>}) + expect(subject).to be_html_safe + end + end +end diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb index 3fe8cf43934..e8a37e8d77b 100644 --- a/spec/lib/gitlab/url_builder_spec.rb +++ b/spec/lib/gitlab/url_builder_spec.rb @@ -97,6 +97,17 @@ describe Gitlab::UrlBuilder, lib: true do end end + context 'on a PersonalSnippet' do + it 'returns a proper URL' do + personal_snippet = create(:personal_snippet) + note = build_stubbed(:note_on_personal_snippet, noteable: personal_snippet) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/snippets/#{note.noteable_id}#note_#{note.id}" + end + end + context 'on another object' do it 'returns a proper URL' do project = build_stubbed(:empty_project) diff --git a/spec/lib/gitlab/url_sanitizer_spec.rb b/spec/lib/gitlab/url_sanitizer_spec.rb index fc144a2556a..6bce724a3f6 100644 --- a/spec/lib/gitlab/url_sanitizer_spec.rb +++ b/spec/lib/gitlab/url_sanitizer_spec.rb @@ -62,11 +62,6 @@ describe Gitlab::UrlSanitizer, lib: true do end end - describe '.http_credentials_for_user' do - it { expect(described_class.http_credentials_for_user(user)).to eq({ user: 'john.doe' }) } - it { expect(described_class.http_credentials_for_user('foo')).to eq({}) } - end - describe '#sanitized_url' do it { expect(url_sanitizer.sanitized_url).to eq("https://github.com/me/project.git") } end @@ -76,7 +71,7 @@ describe Gitlab::UrlSanitizer, lib: true do context 'when user is given to #initialize' do let(:url_sanitizer) do - described_class.new("https://github.com/me/project.git", credentials: described_class.http_credentials_for_user(user)) + described_class.new("https://github.com/me/project.git", credentials: { user: user.username }) end it { expect(url_sanitizer.credentials).to eq({ user: 'john.doe' }) } @@ -94,7 +89,7 @@ describe Gitlab::UrlSanitizer, lib: true do context 'when user is given to #initialize' do let(:url_sanitizer) do - described_class.new("https://github.com/me/project.git", credentials: described_class.http_credentials_for_user(user)) + described_class.new("https://github.com/me/project.git", credentials: { user: user.username }) end it { expect(url_sanitizer.full_url).to eq("https://john.doe@github.com/me/project.git") } diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb index 2b27ff66c09..0d87cf25dbb 100644 --- a/spec/lib/gitlab/user_access_spec.rb +++ b/spec/lib/gitlab/user_access_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::UserAccess, lib: true do let(:project) { create(:project) } let(:user) { create(:user) } - describe 'can_push_to_branch?' do + describe '#can_push_to_branch?' do describe 'push to none protected branch' do it 'returns true if user is a master' do project.team << [user, :master] @@ -143,7 +143,7 @@ describe Gitlab::UserAccess, lib: true do end end - describe 'can_create_tag?' do + describe '#can_create_tag?' do describe 'push to none protected tag' do it 'returns true if user is a master' do project.add_user(user, :master) @@ -211,4 +211,48 @@ describe Gitlab::UserAccess, lib: true do end end end + + describe '#can_delete_branch?' do + describe 'delete unprotected branch' do + it 'returns true if user is a master' do + project.add_user(user, :master) + + expect(access.can_delete_branch?('random_branch')).to be_truthy + end + + it 'returns true if user is a developer' do + project.add_user(user, :developer) + + expect(access.can_delete_branch?('random_branch')).to be_truthy + end + + it 'returns false if user is a reporter' do + project.add_user(user, :reporter) + + expect(access.can_delete_branch?('random_branch')).to be_falsey + end + end + + describe 'delete protected branch' do + let(:branch) { create(:protected_branch, project: project, name: "test") } + + it 'returns true if user is a master' do + project.add_user(user, :master) + + expect(access.can_delete_branch?(branch.name)).to be_truthy + end + + it 'returns false if user is a developer' do + project.add_user(user, :developer) + + expect(access.can_delete_branch?(branch.name)).to be_falsey + end + + it 'returns false if user is a reporter' do + project.add_user(user, :reporter) + + expect(access.can_delete_branch?(branch.name)).to be_falsey + end + end + end end diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb index 56772409989..00941aec380 100644 --- a/spec/lib/gitlab/utils_spec.rb +++ b/spec/lib/gitlab/utils_spec.rb @@ -1,5 +1,7 @@ +require 'spec_helper' + describe Gitlab::Utils, lib: true do - delegate :to_boolean, to: :described_class + delegate :to_boolean, :boolean_to_yes_no, to: :described_class describe '.to_boolean' do it 'accepts booleans' do @@ -30,4 +32,11 @@ describe Gitlab::Utils, lib: true do expect(to_boolean(nil)).to be_nil end end + + describe '.boolean_to_yes_no' do + it 'converts booleans to Yes or No' do + expect(boolean_to_yes_no(true)).to eq('Yes') + expect(boolean_to_yes_no(false)).to eq('No') + end + end end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index 093f9301603..b1999409170 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -214,7 +214,7 @@ describe Gitlab::Workhorse, lib: true do repo_param = { Repository: { path: repo_path, storage_name: 'default', - relative_path: project.full_path + '.git', + relative_path: project.full_path + '.git' } } expect(subject).to include(repo_param) @@ -244,7 +244,7 @@ describe Gitlab::Workhorse, lib: true do context "when git_receive_pack action is passed" do let(:action) { 'git_receive_pack' } - it { expect(subject).not_to include(gitaly_params) } + it { expect(subject).to include(gitaly_params) } end context "when info_refs action is passed" do diff --git a/spec/lib/system_check/simple_executor_spec.rb b/spec/lib/system_check/simple_executor_spec.rb new file mode 100644 index 00000000000..a5c6170cd7d --- /dev/null +++ b/spec/lib/system_check/simple_executor_spec.rb @@ -0,0 +1,223 @@ +require 'spec_helper' +require 'rake_helper' + +describe SystemCheck::SimpleExecutor, lib: true do + class SimpleCheck < SystemCheck::BaseCheck + set_name 'my simple check' + + def check? + true + end + end + + class OtherCheck < SystemCheck::BaseCheck + set_name 'other check' + + def check? + false + end + + def show_error + $stdout.puts 'this is an error text' + end + end + + class SkipCheck < SystemCheck::BaseCheck + set_name 'skip check' + set_skip_reason 'this is a skip reason' + + def skip? + true + end + + def check? + raise 'should not execute this' + end + end + + class MultiCheck < SystemCheck::BaseCheck + set_name 'multi check' + + def multi_check + $stdout.puts 'this is a multi output check' + end + + def check? + raise 'should not execute this' + end + end + + class SkipMultiCheck < SystemCheck::BaseCheck + set_name 'skip multi check' + + def skip? + true + end + + def multi_check + raise 'should not execute this' + end + end + + class RepairCheck < SystemCheck::BaseCheck + set_name 'repair check' + + def check? + false + end + + def repair! + true + end + + def show_error + $stdout.puts 'this is an error message' + end + end + + describe '#component' do + it 'returns stored component name' do + expect(subject.component).to eq('Test') + end + end + + describe '#checks' do + before do + subject << SimpleCheck + end + + it 'returns a set of classes' do + expect(subject.checks).to include(SimpleCheck) + end + end + + describe '#<<' do + before do + subject << SimpleCheck + end + + it 'appends a new check to the Set' do + subject << OtherCheck + stored_checks = subject.checks.to_a + + expect(stored_checks.first).to eq(SimpleCheck) + expect(stored_checks.last).to eq(OtherCheck) + end + + it 'inserts unique itens only' do + subject << SimpleCheck + + expect(subject.checks.size).to eq(1) + end + end + + subject { described_class.new('Test') } + + describe '#execute' do + before do + silence_output + + subject << SimpleCheck + subject << OtherCheck + end + + it 'runs included checks' do + expect(subject).to receive(:run_check).with(SimpleCheck) + expect(subject).to receive(:run_check).with(OtherCheck) + + subject.execute + end + end + + describe '#run_check' do + it 'prints check name' do + expect(SimpleCheck).to receive(:display_name).and_call_original + expect { subject.run_check(SimpleCheck) }.to output(/my simple check/).to_stdout + end + + context 'when check pass' do + it 'prints yes' do + expect_any_instance_of(SimpleCheck).to receive(:check?).and_call_original + expect { subject.run_check(SimpleCheck) }.to output(/ \.\.\. yes/).to_stdout + end + end + + context 'when check fails' do + it 'prints no' do + expect_any_instance_of(OtherCheck).to receive(:check?).and_call_original + expect { subject.run_check(OtherCheck) }.to output(/ \.\.\. no/).to_stdout + end + + it 'displays error message from #show_error' do + expect_any_instance_of(OtherCheck).to receive(:show_error).and_call_original + expect { subject.run_check(OtherCheck) }.to output(/this is an error text/).to_stdout + end + + context 'when check implements #repair!' do + it 'executes #repair!' do + expect_any_instance_of(RepairCheck).to receive(:repair!) + + subject.run_check(RepairCheck) + end + + context 'when repair succeeds' do + it 'does not execute #show_error' do + expect_any_instance_of(RepairCheck).to receive(:repair!).and_call_original + expect_any_instance_of(RepairCheck).not_to receive(:show_error) + + subject.run_check(RepairCheck) + end + end + + context 'when repair fails' do + it 'does not execute #show_error' do + expect_any_instance_of(RepairCheck).to receive(:repair!) { false } + expect_any_instance_of(RepairCheck).to receive(:show_error) + + subject.run_check(RepairCheck) + end + end + end + end + + context 'when check implements skip?' do + it 'executes #skip? method' do + expect_any_instance_of(SkipCheck).to receive(:skip?).and_call_original + + subject.run_check(SkipCheck) + end + + it 'displays #skip_reason' do + expect { subject.run_check(SkipCheck) }.to output(/this is a skip reason/).to_stdout + end + + it 'does not execute #check when #skip? is true' do + expect_any_instance_of(SkipCheck).not_to receive(:check?) + + subject.run_check(SkipCheck) + end + end + + context 'when implements a #multi_check' do + it 'executes #multi_check method' do + expect_any_instance_of(MultiCheck).to receive(:multi_check) + + subject.run_check(MultiCheck) + end + + it 'does not execute #check method' do + expect_any_instance_of(MultiCheck).not_to receive(:check) + + subject.run_check(MultiCheck) + end + + context 'when check implements #skip?' do + it 'executes #skip? method' do + expect_any_instance_of(SkipMultiCheck).to receive(:skip?).and_call_original + + subject.run_check(SkipMultiCheck) + end + end + end + end +end diff --git a/spec/lib/system_check_spec.rb b/spec/lib/system_check_spec.rb new file mode 100644 index 00000000000..23d9beddb08 --- /dev/null +++ b/spec/lib/system_check_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' +require 'rake_helper' + +describe SystemCheck, lib: true do + class SimpleCheck < SystemCheck::BaseCheck + def check? + true + end + end + + class OtherCheck < SystemCheck::BaseCheck + def check? + false + end + end + + before do + silence_output + end + + describe '.run' do + subject { SystemCheck } + + it 'detects execution of SimpleCheck' do + is_expected.to execute_check(SimpleCheck) + + subject.run('Test', [SimpleCheck]) + end + + it 'detects exclusion of OtherCheck in execution' do + is_expected.not_to execute_check(OtherCheck) + + subject.run('Test', [SimpleCheck]) + end + end +end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 1e6260270fe..ec6f6c42eac 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -128,6 +128,15 @@ describe Notify do is_expected.to have_body_text(namespace_project_issue_path(project.namespace, project, issue)) end end + + context 'with a preferred language' do + before { Gitlab::I18n.locale = :es } + after { Gitlab::I18n.use_default_locale } + + it 'always generates the email using the default language' do + is_expected.to have_body_text('foo, bar, and baz') + end + end end describe 'status changed' do diff --git a/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb new file mode 100644 index 00000000000..bd5f85b901d --- /dev/null +++ b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170508170547_add_head_pipeline_for_each_merge_request.rb') + +describe AddHeadPipelineForEachMergeRequest do + let(:migration) { described_class.new } + + let!(:project) { create(:empty_project) } + let!(:forked_project_link) { create(:forked_project_link, forked_from_project: project) } + let!(:other_project) { forked_project_link.forked_to_project } + + let!(:pipeline_1) { create(:ci_pipeline, project: project, ref: "branch_1") } + let!(:pipeline_2) { create(:ci_pipeline, project: other_project, ref: "branch_1") } + let!(:pipeline_3) { create(:ci_pipeline, project: other_project, ref: "branch_1") } + let!(:pipeline_4) { create(:ci_pipeline, project: project, ref: "branch_2") } + + let!(:mr_1) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_1", target_branch: "target_1") } + let!(:mr_2) { create(:merge_request, source_project: other_project, target_project: project, source_branch: "branch_1", target_branch: "target_2") } + let!(:mr_3) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_2", target_branch: "master") } + let!(:mr_4) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_3", target_branch: "master") } + + context "#up" do + context "when source_project and source_branch of pipeline are the same of merge request" do + it "sets head_pipeline_id of given merge requests" do + migration.up + + expect(mr_1.reload.head_pipeline_id).to eq(pipeline_1.id) + expect(mr_2.reload.head_pipeline_id).to eq(pipeline_3.id) + expect(mr_3.reload.head_pipeline_id).to eq(pipeline_4.id) + expect(mr_4.reload.head_pipeline_id).to be_nil + end + end + end +end diff --git a/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb b/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb new file mode 100644 index 00000000000..49e750a3f4d --- /dev/null +++ b/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170502101023_cleanup_namespaceless_pending_delete_projects.rb') + +describe CleanupNamespacelessPendingDeleteProjects do + before do + # Stub after_save callbacks that will fail when Project has no namespace + allow_any_instance_of(Project).to receive(:ensure_dir_exist).and_return(nil) + allow_any_instance_of(Project).to receive(:update_project_statistics).and_return(nil) + end + + describe '#up' do + it 'only cleans up pending delete projects' do + create(:empty_project) + create(:empty_project, pending_delete: true) + project = build(:empty_project, pending_delete: true, namespace_id: nil) + project.save(validate: false) + + expect(NamespacelessProjectDestroyWorker).to receive(:bulk_perform_async).with([[project.id]]) + + described_class.new.up + end + + it 'does nothing when no pending delete projects without namespace found' do + create(:empty_project) + create(:empty_project, pending_delete: true) + + expect(NamespacelessProjectDestroyWorker).not_to receive(:bulk_perform_async) + + described_class.new.up + end + end +end diff --git a/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb b/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb deleted file mode 100644 index 57eb03e3c80..00000000000 --- a/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb +++ /dev/null @@ -1,74 +0,0 @@ -require 'spec_helper' -require Rails.root.join('db', 'post_migrate', '20170301205640_migrate_build_events_to_pipeline_events.rb') - -# This migration uses multiple threads, and thus different transactions. This -# means data created in this spec may not be visible to some threads. To work -# around this we use the TRUNCATE cleaning strategy. -describe MigrateBuildEventsToPipelineEvents, truncate: true do - let(:migration) { described_class.new } - let(:project_with_pipeline_service) { create(:empty_project) } - let(:project_with_build_service) { create(:empty_project) } - - before do - ActiveRecord::Base.connection.execute <<-SQL - INSERT INTO services (properties, build_events, pipeline_events, type) - VALUES - ('{"notify_only_broken_builds":true}', true, false, 'SlackService') - , ('{"notify_only_broken_builds":true}', true, false, 'MattermostService') - , ('{"notify_only_broken_builds":true}', true, false, 'HipchatService') - ; - SQL - - ActiveRecord::Base.connection.execute <<-SQL - INSERT INTO services - (properties, build_events, pipeline_events, type, project_id) - VALUES - ('{"notify_only_broken_builds":true}', true, false, - 'BuildsEmailService', #{project_with_pipeline_service.id}) - , ('{"notify_only_broken_pipelines":true}', false, true, - 'PipelinesEmailService', #{project_with_pipeline_service.id}) - , ('{"notify_only_broken_builds":true}', true, false, - 'BuildsEmailService', #{project_with_build_service.id}) - ; - SQL - end - - describe '#up' do - before do - silence_migration = Module.new do - # rubocop:disable Rails/Delegate - def execute(query) - connection.execute(query) - end - end - - migration.extend(silence_migration) - migration.up - end - - it 'migrates chat service properly' do - [SlackService, MattermostService, HipchatService].each do |service| - expect(service.count).to eq(1) - - verify_service_record(service.first) - end - end - - it 'migrates pipelines email service only if it has none before' do - Project.find_each do |project| - pipeline_service_count = - project.services.where(type: 'PipelinesEmailService').count - - expect(pipeline_service_count).to eq(1) - - verify_service_record(project.pipelines_email_service) - end - end - - def verify_service_record(service) - expect(service.notify_only_broken_pipelines).to be(true) - expect(service.build_events).to be(false) - expect(service.pipeline_events).to be(true) - end - end -end diff --git a/spec/migrations/migrate_build_stage_reference_spec.rb b/spec/migrations/migrate_build_stage_reference_spec.rb new file mode 100644 index 00000000000..80b321860c2 --- /dev/null +++ b/spec/migrations/migrate_build_stage_reference_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170526185921_migrate_build_stage_reference.rb') + +describe MigrateBuildStageReference, :migration do + ## + # Create test data - pipeline and CI/CD jobs. + # + + let(:jobs) { table(:ci_builds) } + let(:stages) { table(:ci_stages) } + let(:pipelines) { table(:ci_pipelines) } + let(:projects) { table(:projects) } + + before do + # Create projects + # + projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1') + projects.create!(id: 456, name: 'gitlab2', path: 'gitlab2') + + # Create CI/CD pipelines + # + pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a') + pipelines.create!(id: 2, project_id: 456, ref: 'feature', sha: '21a3deb') + + # Create CI/CD jobs + # + jobs.create!(id: 1, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build') + jobs.create!(id: 2, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build') + jobs.create!(id: 3, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test') + jobs.create!(id: 4, commit_id: 1, project_id: 123, stage_idx: 3, stage: 'deploy') + jobs.create!(id: 5, commit_id: 2, project_id: 456, stage_idx: 2, stage: 'test:2') + jobs.create!(id: 6, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1') + jobs.create!(id: 7, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1') + jobs.create!(id: 8, commit_id: 3, project_id: 789, stage_idx: 3, stage: 'deploy') + + # Create CI/CD stages + # + stages.create(id: 101, pipeline_id: 1, project_id: 123, name: 'test') + stages.create(id: 102, pipeline_id: 1, project_id: 123, name: 'build') + stages.create(id: 103, pipeline_id: 1, project_id: 123, name: 'deploy') + stages.create(id: 104, pipeline_id: 2, project_id: 456, name: 'test:1') + stages.create(id: 105, pipeline_id: 2, project_id: 456, name: 'test:2') + stages.create(id: 106, pipeline_id: 2, project_id: 456, name: 'deploy') + end + + it 'correctly migrate build stage references' do + expect(jobs.where(stage_id: nil).count).to eq 8 + + migrate! + + expect(jobs.where(stage_id: nil).count).to eq 1 + + expect(jobs.find(1).stage_id).to eq 102 + expect(jobs.find(2).stage_id).to eq 102 + expect(jobs.find(3).stage_id).to eq 101 + expect(jobs.find(4).stage_id).to eq 103 + expect(jobs.find(5).stage_id).to eq 105 + expect(jobs.find(6).stage_id).to eq 104 + expect(jobs.find(7).stage_id).to eq 104 + expect(jobs.find(8).stage_id).to eq nil + end +end diff --git a/spec/migrations/migrate_old_artifacts_spec.rb b/spec/migrations/migrate_old_artifacts_spec.rb new file mode 100644 index 00000000000..50f4bbda001 --- /dev/null +++ b/spec/migrations/migrate_old_artifacts_spec.rb @@ -0,0 +1,117 @@ +# encoding: utf-8 + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170523083112_migrate_old_artifacts.rb') + +describe MigrateOldArtifacts do + let(:migration) { described_class.new } + let!(:directory) { Dir.mktmpdir } + + before do + allow(Gitlab.config.artifacts).to receive(:path).and_return(directory) + end + + after do + FileUtils.remove_entry_secure(directory) + end + + context 'with migratable data' do + let(:project1) { create(:empty_project, ci_id: 2) } + let(:project2) { create(:empty_project, ci_id: 3) } + let(:project3) { create(:empty_project) } + + let(:pipeline1) { create(:ci_empty_pipeline, project: project1) } + let(:pipeline2) { create(:ci_empty_pipeline, project: project2) } + let(:pipeline3) { create(:ci_empty_pipeline, project: project3) } + + let!(:build_with_legacy_artifacts) { create(:ci_build, pipeline: pipeline1) } + let!(:build_without_artifacts) { create(:ci_build, pipeline: pipeline1) } + let!(:build2) { create(:ci_build, :artifacts, pipeline: pipeline2) } + let!(:build3) { create(:ci_build, :artifacts, pipeline: pipeline3) } + + before do + store_artifacts_in_legacy_path(build_with_legacy_artifacts) + end + + it "legacy artifacts are not accessible" do + expect(build_with_legacy_artifacts.artifacts?).to be_falsey + end + + it "legacy artifacts are set" do + expect(build_with_legacy_artifacts.artifacts_file_identifier).not_to be_nil + end + + describe '#min_id' do + subject { migration.send(:min_id) } + + it 'returns the newest build for which ci_id is not defined' do + is_expected.to eq(build3.id) + end + end + + describe '#builds_with_artifacts' do + subject { migration.send(:builds_with_artifacts).map(&:id) } + + it 'returns a list of builds that has artifacts and could be migrated' do + is_expected.to contain_exactly(build_with_legacy_artifacts.id, build2.id) + end + end + + describe '#up' do + context 'when migrating artifacts' do + before do + migration.up + end + + it 'all files do have artifacts' do + Ci::Build.with_artifacts do |build| + expect(build).to have_artifacts + end + end + + it 'artifacts are no longer present on legacy path' do + expect(File.exist?(legacy_path(build_with_legacy_artifacts))).to eq(false) + end + end + + context 'when there are aritfacts in old and new directory' do + before do + store_artifacts_in_legacy_path(build2) + + migration.up + end + + it 'does not move old files' do + expect(File.exist?(legacy_path(build2))).to eq(true) + end + end + end + + private + + def store_artifacts_in_legacy_path(build) + FileUtils.mkdir_p(legacy_path(build)) + + FileUtils.copy( + Rails.root.join('spec/fixtures/ci_build_artifacts.zip'), + File.join(legacy_path(build), "ci_build_artifacts.zip")) + + FileUtils.copy( + Rails.root.join('spec/fixtures/ci_build_artifacts_metadata.gz'), + File.join(legacy_path(build), "ci_build_artifacts_metadata.gz")) + + build.update_columns( + artifacts_file: 'ci_build_artifacts.zip', + artifacts_metadata: 'ci_build_artifacts_metadata.gz') + + build.reload + end + + def legacy_path(build) + File.join(directory, + build.created_at.utc.strftime('%Y_%m'), + build.project.ci_id.to_s, + build.id.to_s) + end + end +end diff --git a/spec/migrations/migrate_pipeline_stages_spec.rb b/spec/migrations/migrate_pipeline_stages_spec.rb new file mode 100644 index 00000000000..c47f2bb8ff9 --- /dev/null +++ b/spec/migrations/migrate_pipeline_stages_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170526185842_migrate_pipeline_stages.rb') + +describe MigratePipelineStages, :migration do + ## + # Create test data - pipeline and CI/CD jobs. + # + + let(:jobs) { table(:ci_builds) } + let(:stages) { table(:ci_stages) } + let(:pipelines) { table(:ci_pipelines) } + let(:projects) { table(:projects) } + + before do + # Create projects + # + projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1') + projects.create!(id: 456, name: 'gitlab2', path: 'gitlab2') + + # Create CI/CD pipelines + # + pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a') + pipelines.create!(id: 2, project_id: 456, ref: 'feature', sha: '21a3deb') + + # Create CI/CD jobs + # + jobs.create!(id: 1, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build') + jobs.create!(id: 2, commit_id: 1, project_id: 123, stage_idx: 2, stage: 'build') + jobs.create!(id: 3, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test') + jobs.create!(id: 4, commit_id: 1, project_id: 123, stage_idx: 1, stage: 'test') + jobs.create!(id: 5, commit_id: 1, project_id: 123, stage_idx: 3, stage: 'deploy') + jobs.create!(id: 6, commit_id: 2, project_id: 456, stage_idx: 3, stage: 'deploy') + jobs.create!(id: 7, commit_id: 2, project_id: 456, stage_idx: 2, stage: 'test:2') + jobs.create!(id: 8, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1') + jobs.create!(id: 9, commit_id: 2, project_id: 456, stage_idx: 1, stage: 'test:1') + jobs.create!(id: 10, commit_id: 2, project_id: 456, stage_idx: 2, stage: 'test:2') + jobs.create!(id: 11, commit_id: 3, project_id: 456, stage_idx: 3, stage: 'deploy') + jobs.create!(id: 12, commit_id: 2, project_id: 789, stage_idx: 3, stage: 'deploy') + end + + it 'correctly migrates pipeline stages' do + expect(stages.count).to be_zero + + migrate! + + expect(stages.count).to eq 6 + expect(stages.all.pluck(:name)) + .to match_array %w[test build deploy test:1 test:2 deploy] + expect(stages.where(pipeline_id: 1).order(:id).pluck(:name)) + .to eq %w[test build deploy] + expect(stages.where(pipeline_id: 2).order(:id).pluck(:name)) + .to eq %w[test:1 test:2 deploy] + expect(stages.where(pipeline_id: 3).count).to be_zero + expect(stages.where(project_id: 789).count).to be_zero + end +end diff --git a/spec/migrations/migrate_user_project_view_spec.rb b/spec/migrations/migrate_user_project_view_spec.rb index dacaa834aa9..70f8e0d6082 100644 --- a/spec/migrations/migrate_user_project_view_spec.rb +++ b/spec/migrations/migrate_user_project_view_spec.rb @@ -5,7 +5,12 @@ require Rails.root.join('db', 'post_migrate', '20170406142253_migrate_user_proje describe MigrateUserProjectView do let(:migration) { described_class.new } - let!(:user) { create(:user, project_view: 'readme') } + let!(:user) { create(:user) } + + before do + # 0 is the numeric value for the old 'readme' option + user.update_column(:project_view, 0) + end describe '#up' do it 'updates project view setting with new value' do diff --git a/spec/migrations/update_retried_for_ci_build_spec.rb b/spec/migrations/update_retried_for_ci_build_spec.rb new file mode 100644 index 00000000000..3742b4dafe5 --- /dev/null +++ b/spec/migrations/update_retried_for_ci_build_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170503004427_update_retried_for_ci_build.rb') + +describe UpdateRetriedForCiBuild, truncate: true do + let(:pipeline) { create(:ci_pipeline) } + let!(:build_old) { create(:ci_build, pipeline: pipeline, name: 'test') } + let!(:build_new) { create(:ci_build, pipeline: pipeline, name: 'test') } + + before do + described_class.new.up + end + + it 'updates ci_builds.is_retried' do + expect(build_old.reload).to be_retried + expect(build_new.reload).not_to be_retried + end +end diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index ced93c8f762..90aec2b45e6 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -28,9 +28,7 @@ RSpec.describe AbuseReport, type: :model do end it 'lets a worker delete the user' do - expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, - delete_solo_owned_groups: true, - hard_delete: true) + expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, hard_delete: true) subject.remove_user(deleted_by: user) end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 119482b5f32..fa229542f70 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -89,7 +89,7 @@ describe ApplicationSetting, models: true do storages = { 'custom1' => 'tmp/tests/custom_repositories_1', 'custom2' => 'tmp/tests/custom_repositories_2', - 'custom3' => 'tmp/tests/custom_repositories_3', + 'custom3' => 'tmp/tests/custom_repositories_3' } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index f84c6b48173..f19e1af65a6 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -271,6 +271,52 @@ describe Blob do end end + describe '#auxiliary_viewer' do + context 'when the blob has an external storage error' do + before do + project.lfs_enabled = false + end + + it 'returns nil' do + blob = fake_blob(path: 'LICENSE', lfs: true) + + expect(blob.auxiliary_viewer).to be_nil + end + end + + context 'when the blob is empty' do + it 'returns nil' do + blob = fake_blob(data: '') + + expect(blob.auxiliary_viewer).to be_nil + end + end + + context 'when the blob is stored externally' do + it 'returns a matching viewer' do + blob = fake_blob(path: 'LICENSE', lfs: true) + + expect(blob.auxiliary_viewer).to be_a(BlobViewer::License) + end + end + + context 'when the blob is binary' do + it 'returns nil' do + blob = fake_blob(path: 'LICENSE', binary: true) + + expect(blob.auxiliary_viewer).to be_nil + end + end + + context 'when the blob is text-based' do + it 'returns a matching text-based viewer' do + blob = fake_blob(path: 'LICENSE') + + expect(blob.auxiliary_viewer).to be_a(BlobViewer::License) + end + end + end + describe '#rendered_as_text?' do context 'when ignoring errors' do context 'when the simple viewer is text-based' do diff --git a/spec/models/blob_viewer/base_spec.rb b/spec/models/blob_viewer/base_spec.rb index 740ad9d275e..d56379eb59d 100644 --- a/spec/models/blob_viewer/base_spec.rb +++ b/spec/models/blob_viewer/base_spec.rb @@ -7,10 +7,12 @@ describe BlobViewer::Base, model: true do let(:viewer_class) do Class.new(described_class) do + include BlobViewer::ServerSide + self.extensions = %w(pdf) - self.max_size = 1.megabyte - self.absolute_max_size = 5.megabytes - self.client_side = false + self.binary = true + self.collapse_limit = 1.megabyte + self.size_limit = 5.megabytes end end @@ -18,93 +20,98 @@ describe BlobViewer::Base, model: true do describe '.can_render?' do context 'when the extension is supported' do - let(:blob) { fake_blob(path: 'file.pdf') } + context 'when the binaryness matches' do + let(:blob) { fake_blob(path: 'file.pdf', binary: true) } - it 'returns true' do - expect(viewer_class.can_render?(blob)).to be_truthy + it 'returns true' do + expect(viewer_class.can_render?(blob)).to be_truthy + end end - end - context 'when the extension is not supported' do - let(:blob) { fake_blob(path: 'file.txt') } + context 'when the binaryness does not match' do + let(:blob) { fake_blob(path: 'file.pdf', binary: false) } - it 'returns false' do - expect(viewer_class.can_render?(blob)).to be_falsey + it 'returns false' do + expect(viewer_class.can_render?(blob)).to be_falsey + end end end - end - describe '#too_large?' do - context 'when the blob size is larger than the max size' do - let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + context 'when the file type is supported' do + before do + viewer_class.file_types = %i(license) + viewer_class.binary = false + end - it 'returns true' do - expect(viewer.too_large?).to be_truthy + context 'when the binaryness matches' do + let(:blob) { fake_blob(path: 'LICENSE', binary: false) } + + it 'returns true' do + expect(viewer_class.can_render?(blob)).to be_truthy + end + end + + context 'when the binaryness does not match' do + let(:blob) { fake_blob(path: 'LICENSE', binary: true) } + + it 'returns false' do + expect(viewer_class.can_render?(blob)).to be_falsey + end end end - context 'when the blob size is smaller than the max size' do - let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } + context 'when the extension and file type are not supported' do + let(:blob) { fake_blob(path: 'file.txt') } it 'returns false' do - expect(viewer.too_large?).to be_falsey + expect(viewer_class.can_render?(blob)).to be_falsey end end end - describe '#absolutely_too_large?' do - context 'when the blob size is larger than the absolute max size' do - let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } + describe '#collapsed?' do + context 'when the blob size is larger than the collapse limit' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns true' do - expect(viewer.absolutely_too_large?).to be_truthy + expect(viewer.collapsed?).to be_truthy end end - context 'when the blob size is smaller than the absolute max size' do - let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + context 'when the blob size is smaller than the collapse limit' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } it 'returns false' do - expect(viewer.absolutely_too_large?).to be_falsey + expect(viewer.collapsed?).to be_falsey end end end - describe '#can_override_max_size?' do - context 'when the blob size is larger than the max size' do - context 'when the blob size is larger than the absolute max size' do - let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } - - it 'returns false' do - expect(viewer.can_override_max_size?).to be_falsey - end - end - - context 'when the blob size is smaller than the absolute max size' do - let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + describe '#too_large?' do + context 'when the blob size is larger than the size limit' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } - it 'returns true' do - expect(viewer.can_override_max_size?).to be_truthy - end + it 'returns true' do + expect(viewer.too_large?).to be_truthy end end - context 'when the blob size is smaller than the max size' do - let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } + context 'when the blob size is smaller than the size limit' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns false' do - expect(viewer.can_override_max_size?).to be_falsey + expect(viewer.too_large?).to be_falsey end end end describe '#render_error' do - context 'when the max size is overridden' do + context 'when expanded' do before do - viewer.override_max_size = true + viewer.expanded = true end - context 'when the blob size is larger than the absolute max size' do + context 'when the blob size is larger than the size limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } it 'returns :too_large' do @@ -112,7 +119,7 @@ describe BlobViewer::Base, model: true do end end - context 'when the blob size is smaller than the absolute max size' do + context 'when the blob size is smaller than the size limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns nil' do @@ -121,16 +128,16 @@ describe BlobViewer::Base, model: true do end end - context 'when the max size is not overridden' do - context 'when the blob size is larger than the max size' do + context 'when not expanded' do + context 'when the blob size is larger than the collapse limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } - it 'returns :too_large' do - expect(viewer.render_error).to eq(:too_large) + it 'returns :collapsed' do + expect(viewer.render_error).to eq(:collapsed) end end - context 'when the blob size is smaller than the max size' do + context 'when the blob size is smaller than the collapse limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } it 'returns nil' do @@ -138,49 +145,5 @@ describe BlobViewer::Base, model: true do end end end - - context 'when the viewer is server side but the blob is stored externally' do - let(:project) { build(:empty_project, lfs_enabled: true) } - - let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } - - before do - allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) - end - - it 'return :server_side_but_stored_externally' do - expect(viewer.render_error).to eq(:server_side_but_stored_externally) - end - end - end - - describe '#prepare!' do - context 'when the viewer is server side' do - let(:blob) { fake_blob(path: 'file.md') } - - before do - viewer_class.client_side = false - end - - it 'loads all blob data' do - expect(blob).to receive(:load_all_data!) - - viewer.prepare! - end - end - - context 'when the viewer is client side' do - let(:blob) { fake_blob(path: 'file.md') } - - before do - viewer_class.client_side = true - end - - it "doesn't load all blob data" do - expect(blob).not_to receive(:load_all_data!) - - viewer.prepare! - end - end end end diff --git a/spec/models/blob_viewer/changelog_spec.rb b/spec/models/blob_viewer/changelog_spec.rb new file mode 100644 index 00000000000..9066c5a05ac --- /dev/null +++ b/spec/models/blob_viewer/changelog_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe BlobViewer::Changelog, model: true do + include FakeBlobHelpers + + let(:project) { create(:project, :repository) } + let(:blob) { fake_blob(path: 'CHANGELOG') } + subject { described_class.new(blob) } + + describe '#render_error' do + context 'when there are no tags' do + before do + allow(project.repository).to receive(:tag_count).and_return(0) + end + + it 'returns :no_tags' do + expect(subject.render_error).to eq(:no_tags) + end + end + + context 'when there are tags' do + it 'returns nil' do + expect(subject.render_error).to be_nil + end + end + end +end diff --git a/spec/models/blob_viewer/composer_json_spec.rb b/spec/models/blob_viewer/composer_json_spec.rb new file mode 100644 index 00000000000..df4f1f4815c --- /dev/null +++ b/spec/models/blob_viewer/composer_json_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe BlobViewer::ComposerJson, model: true do + include FakeBlobHelpers + + let(:project) { build(:project) } + let(:data) do + <<-SPEC.strip_heredoc + { + "name": "laravel/laravel", + "homepage": "https://laravel.com/" + } + SPEC + end + let(:blob) { fake_blob(path: 'composer.json', data: data) } + subject { described_class.new(blob) } + + describe '#package_name' do + it 'returns the package name' do + expect(subject).to receive(:prepare!) + + expect(subject.package_name).to eq('laravel/laravel') + end + end +end diff --git a/spec/models/blob_viewer/gemspec_spec.rb b/spec/models/blob_viewer/gemspec_spec.rb new file mode 100644 index 00000000000..81e932de290 --- /dev/null +++ b/spec/models/blob_viewer/gemspec_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe BlobViewer::Gemspec, model: true do + include FakeBlobHelpers + + let(:project) { build(:project) } + let(:data) do + <<-SPEC.strip_heredoc + Gem::Specification.new do |s| + s.platform = Gem::Platform::RUBY + s.name = "activerecord" + end + SPEC + end + let(:blob) { fake_blob(path: 'activerecord.gemspec', data: data) } + subject { described_class.new(blob) } + + describe '#package_name' do + it 'returns the package name' do + expect(subject).to receive(:prepare!) + + expect(subject.package_name).to eq('activerecord') + end + end +end diff --git a/spec/models/blob_viewer/gitlab_ci_yml_spec.rb b/spec/models/blob_viewer/gitlab_ci_yml_spec.rb new file mode 100644 index 00000000000..0c6c24ece21 --- /dev/null +++ b/spec/models/blob_viewer/gitlab_ci_yml_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe BlobViewer::GitlabCiYml, model: true do + include FakeBlobHelpers + + let(:project) { build(:project) } + let(:data) { File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) } + let(:blob) { fake_blob(path: '.gitlab-ci.yml', data: data) } + subject { described_class.new(blob) } + + describe '#validation_message' do + it 'calls prepare! on the viewer' do + expect(subject).to receive(:prepare!) + + subject.validation_message + end + + context 'when the configuration is valid' do + it 'returns nil' do + expect(subject.validation_message).to be_nil + end + end + + context 'when the configuration is invalid' do + let(:data) { 'oof' } + + it 'returns the error message' do + expect(subject.validation_message).to eq('Invalid configuration format') + end + end + end +end diff --git a/spec/models/blob_viewer/license_spec.rb b/spec/models/blob_viewer/license_spec.rb new file mode 100644 index 00000000000..944ddd32b92 --- /dev/null +++ b/spec/models/blob_viewer/license_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe BlobViewer::License, model: true do + include FakeBlobHelpers + + let(:project) { create(:project, :repository) } + let(:blob) { fake_blob(path: 'LICENSE') } + subject { described_class.new(blob) } + + describe '#license' do + it 'returns the blob project repository license' do + expect(subject.license).not_to be_nil + expect(subject.license).to eq(project.repository.license) + end + end + + describe '#render_error' do + context 'when there is no license' do + before do + allow(project.repository).to receive(:license).and_return(nil) + end + + it 'returns :unknown_license' do + expect(subject.render_error).to eq(:unknown_license) + end + end + + context 'when there is a license' do + it 'returns nil' do + expect(subject.render_error).to be_nil + end + end + end +end diff --git a/spec/models/blob_viewer/package_json_spec.rb b/spec/models/blob_viewer/package_json_spec.rb new file mode 100644 index 00000000000..5c9a9c81963 --- /dev/null +++ b/spec/models/blob_viewer/package_json_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe BlobViewer::PackageJson, model: true do + include FakeBlobHelpers + + let(:project) { build(:project) } + let(:data) do + <<-SPEC.strip_heredoc + { + "name": "module-name", + "version": "10.3.1" + } + SPEC + end + let(:blob) { fake_blob(path: 'package.json', data: data) } + subject { described_class.new(blob) } + + describe '#package_name' do + it 'returns the package name' do + expect(subject).to receive(:prepare!) + + expect(subject.package_name).to eq('module-name') + end + end +end diff --git a/spec/models/blob_viewer/podspec_json_spec.rb b/spec/models/blob_viewer/podspec_json_spec.rb new file mode 100644 index 00000000000..42a00940bc5 --- /dev/null +++ b/spec/models/blob_viewer/podspec_json_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe BlobViewer::PodspecJson, model: true do + include FakeBlobHelpers + + let(:project) { build(:project) } + let(:data) do + <<-SPEC.strip_heredoc + { + "name": "AFNetworking", + "version": "2.0.0" + } + SPEC + end + let(:blob) { fake_blob(path: 'AFNetworking.podspec.json', data: data) } + subject { described_class.new(blob) } + + describe '#package_name' do + it 'returns the package name' do + expect(subject).to receive(:prepare!) + + expect(subject.package_name).to eq('AFNetworking') + end + end +end diff --git a/spec/models/blob_viewer/podspec_spec.rb b/spec/models/blob_viewer/podspec_spec.rb new file mode 100644 index 00000000000..6c9f0f42d53 --- /dev/null +++ b/spec/models/blob_viewer/podspec_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe BlobViewer::Podspec, model: true do + include FakeBlobHelpers + + let(:project) { build(:project) } + let(:data) do + <<-SPEC.strip_heredoc + Pod::Spec.new do |spec| + spec.name = 'Reachability' + spec.version = '3.1.0' + end + SPEC + end + let(:blob) { fake_blob(path: 'Reachability.podspec', data: data) } + subject { described_class.new(blob) } + + describe '#package_name' do + it 'returns the package name' do + expect(subject).to receive(:prepare!) + + expect(subject.package_name).to eq('Reachability') + end + end +end diff --git a/spec/models/blob_viewer/route_map_spec.rb b/spec/models/blob_viewer/route_map_spec.rb new file mode 100644 index 00000000000..4854e0262d9 --- /dev/null +++ b/spec/models/blob_viewer/route_map_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe BlobViewer::RouteMap, model: true do + include FakeBlobHelpers + + let(:project) { build(:project) } + let(:data) do + <<-MAP.strip_heredoc + # Team data + - source: 'data/team.yml' + public: 'team/' + MAP + end + let(:blob) { fake_blob(path: '.gitlab/route-map.yml', data: data) } + subject { described_class.new(blob) } + + describe '#validation_message' do + it 'calls prepare! on the viewer' do + expect(subject).to receive(:prepare!) + + subject.validation_message + end + + context 'when the configuration is valid' do + it 'returns nil' do + expect(subject.validation_message).to be_nil + end + end + + context 'when the configuration is invalid' do + let(:data) { 'oof' } + + it 'returns the error message' do + expect(subject.validation_message).to eq('Route map is not an array') + end + end + end +end diff --git a/spec/models/blob_viewer/server_side_spec.rb b/spec/models/blob_viewer/server_side_spec.rb new file mode 100644 index 00000000000..f047953d540 --- /dev/null +++ b/spec/models/blob_viewer/server_side_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe BlobViewer::ServerSide, model: true do + include FakeBlobHelpers + + let(:project) { build(:empty_project) } + + let(:viewer_class) do + Class.new(BlobViewer::Base) do + include BlobViewer::ServerSide + end + end + + subject { viewer_class.new(blob) } + + describe '#prepare!' do + let(:blob) { fake_blob(path: 'file.txt') } + + it 'loads all blob data' do + expect(blob).to receive(:load_all_data!) + + subject.prepare! + end + end + + describe '#render_error' do + context 'when the blob is stored externally' do + let(:project) { build(:empty_project, lfs_enabled: true) } + + let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end + + it 'return :server_side_but_stored_externally' do + expect(subject.render_error).to eq(:server_side_but_stored_externally) + end + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 5231ce28c9d..b0716e04d3d 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -427,6 +427,42 @@ describe Ci::Build, :models do end end + describe '#environment_url' do + subject { job.environment_url } + + context 'when yaml environment uses $CI_COMMIT_REF_NAME' do + let(:job) do + create(:ci_build, + ref: 'master', + options: { environment: { url: 'http://review/$CI_COMMIT_REF_NAME' } }) + end + + it { is_expected.to eq('http://review/master') } + end + + context 'when yaml environment uses yaml_variables containing symbol keys' do + let(:job) do + create(:ci_build, + yaml_variables: [{ key: :APP_HOST, value: 'host' }], + options: { environment: { url: 'http://review/$APP_HOST' } }) + end + + it { is_expected.to eq('http://review/host') } + end + + context 'when yaml environment does not have url' do + let(:job) { create(:ci_build, environment: 'staging') } + + let!(:environment) do + create(:environment, project: job.project, name: job.environment) + end + + it 'returns the external_url from persisted environment' do + is_expected.to eq(environment.external_url) + end + end + end + describe '#starts_environment?' do subject { build.starts_environment? } @@ -918,6 +954,10 @@ describe Ci::Build, :models do it { is_expected.to eq(environment) } end + + context 'when there is no environment' do + it { is_expected.to be_nil } + end end describe '#play' do @@ -972,7 +1012,7 @@ describe Ci::Build, :models do 'fix-1-foo' => 'fix-1-foo', 'a' * 63 => 'a' * 63, 'a' * 64 => 'a' * 63, - 'FOO' => 'foo', + 'FOO' => 'foo' }.each do |ref, slug| it "transforms #{ref} to #{slug}" do build.ref = ref @@ -1139,12 +1179,13 @@ describe Ci::Build, :models do { key: 'CI_PROJECT_ID', value: project.id.to_s, public: true }, { key: 'CI_PROJECT_NAME', value: project.path, public: true }, { key: 'CI_PROJECT_PATH', value: project.full_path, public: true }, + { key: 'CI_PROJECT_PATH_SLUG', value: project.full_path.parameterize, public: true }, { key: 'CI_PROJECT_NAMESPACE', value: project.namespace.full_path, public: true }, { key: 'CI_PROJECT_URL', value: project.web_url, public: true }, { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true }, { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true }, { key: 'CI_REGISTRY_PASSWORD', value: build.token, public: false }, - { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false }, + { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false } ] end @@ -1176,11 +1217,6 @@ describe Ci::Build, :models do end context 'when build has an environment' do - before do - build.update(environment: 'production') - create(:environment, project: build.project, name: 'production', slug: 'prod-slug') - end - let(:environment_variables) do [ { key: 'CI_ENVIRONMENT_NAME', value: 'production', public: true }, @@ -1188,7 +1224,56 @@ describe Ci::Build, :models do ] end - it { environment_variables.each { |v| is_expected.to include(v) } } + let!(:environment) do + create(:environment, + project: build.project, + name: 'production', + slug: 'prod-slug', + external_url: '') + end + + before do + build.update(environment: 'production') + end + + shared_examples 'containing environment variables' do + it { environment_variables.each { |v| is_expected.to include(v) } } + end + + context 'when no URL was set' do + it_behaves_like 'containing environment variables' + + it 'does not have CI_ENVIRONMENT_URL' do + keys = subject.map { |var| var[:key] } + + expect(keys).not_to include('CI_ENVIRONMENT_URL') + end + end + + context 'when an URL was set' do + let(:url) { 'http://host/test' } + + before do + environment_variables << + { key: 'CI_ENVIRONMENT_URL', value: url, public: true } + end + + context 'when the URL was set from the job' do + before do + build.update(options: { environment: { url: 'http://host/$CI_JOB_NAME' } }) + end + + it_behaves_like 'containing environment variables' + end + + context 'when the URL was not set from the job, but environment' do + before do + environment.update(external_url: url) + end + + it_behaves_like 'containing environment variables' + end + end end context 'when build started manually' do @@ -1215,16 +1300,49 @@ describe Ci::Build, :models do it { is_expected.to include(tag_variable) } end - context 'when secure variable is defined' do - let(:secure_variable) do + context 'when secret variable is defined' do + let(:secret_variable) do { key: 'SECRET_KEY', value: 'secret_value', public: false } end before do - build.project.variables << Ci::Variable.new(key: 'SECRET_KEY', value: 'secret_value') + create(:ci_variable, + secret_variable.slice(:key, :value).merge(project: project)) + end + + it { is_expected.to include(secret_variable) } + end + + context 'when protected variable is defined' do + let(:protected_variable) do + { key: 'PROTECTED_KEY', value: 'protected_value', public: false } + end + + before do + create(:ci_variable, + :protected, + protected_variable.slice(:key, :value).merge(project: project)) + end + + context 'when the branch is protected' do + before do + create(:protected_branch, project: build.project, name: build.ref) + end + + it { is_expected.to include(protected_variable) } end - it { is_expected.to include(secure_variable) } + context 'when the tag is protected' do + before do + create(:protected_tag, project: build.project, name: build.ref) + end + + it { is_expected.to include(protected_variable) } + end + + context 'when the ref is not protected' do + it { is_expected.not_to include(protected_variable) } + end end context 'when build is for triggers' do @@ -1346,15 +1464,30 @@ describe Ci::Build, :models do end context 'returns variables in valid order' do + let(:build_pre_var) { { key: 'build', value: 'value' } } + let(:project_pre_var) { { key: 'project', value: 'value' } } + let(:pipeline_pre_var) { { key: 'pipeline', value: 'value' } } + let(:build_yaml_var) { { key: 'yaml', value: 'value' } } + before do - allow(build).to receive(:predefined_variables) { ['predefined'] } - allow(project).to receive(:predefined_variables) { ['project'] } - allow(pipeline).to receive(:predefined_variables) { ['pipeline'] } - allow(build).to receive(:yaml_variables) { ['yaml'] } - allow(project).to receive(:secret_variables) { ['secret'] } + allow(build).to receive(:predefined_variables) { [build_pre_var] } + allow(project).to receive(:predefined_variables) { [project_pre_var] } + allow(pipeline).to receive(:predefined_variables) { [pipeline_pre_var] } + allow(build).to receive(:yaml_variables) { [build_yaml_var] } + + allow(project).to receive(:secret_variables_for).with(build.ref) do + [create(:ci_variable, key: 'secret', value: 'value')] + end end - it { is_expected.to eq(%w[predefined project pipeline yaml secret]) } + it do + is_expected.to eq( + [build_pre_var, + project_pre_var, + pipeline_pre_var, + build_yaml_var, + { key: 'secret', value: 'value', public: false }]) + end end end diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/legacy_stage_spec.rb index 8f6ab908987..48116c7e701 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/legacy_stage_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Ci::Stage, models: true do +describe Ci::LegacyStage, :models do let(:stage) { build(:ci_stage) } let(:pipeline) { stage.pipeline } let(:stage_name) { stage.name } diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb index 822b98c5f6c..b00e7a73571 100644 --- a/spec/models/ci/pipeline_schedule_spec.rb +++ b/spec/models/ci/pipeline_schedule_spec.rb @@ -25,6 +25,14 @@ describe Ci::PipelineSchedule, models: true do expect(pipeline_schedule).not_to be_valid end + + context 'when active is false' do + it 'does not allow nullified ref' do + pipeline_schedule = build(:ci_pipeline_schedule, :inactive, ref: nil) + + expect(pipeline_schedule).not_to be_valid + end + end end describe '#set_next_run_at' do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 72c8dccb185..b50c7700bd3 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -21,13 +21,35 @@ describe Ci::Pipeline, models: true do it { is_expected.to have_many(:auto_canceled_pipelines) } it { is_expected.to have_many(:auto_canceled_jobs) } - it { is_expected.to validate_presence_of :sha } - it { is_expected.to validate_presence_of :status } + it { is_expected.to validate_presence_of(:sha) } + it { is_expected.to validate_presence_of(:status) } it { is_expected.to respond_to :git_author_name } it { is_expected.to respond_to :git_author_email } it { is_expected.to respond_to :short_sha } + describe '#source' do + context 'when creating new pipeline' do + let(:pipeline) do + build(:ci_empty_pipeline, status: :created, project: project, source: nil) + end + + it "prevents from creating an object" do + expect(pipeline).not_to be_valid + end + end + + context 'when updating existing pipeline' do + before do + pipeline.update_attribute(:source, nil) + end + + it "object is valid" do + expect(pipeline).to be_valid + end + end + end + describe '#block' do it 'changes pipeline status to manual' do expect(pipeline.block).to be true @@ -202,8 +224,19 @@ describe Ci::Pipeline, models: true do status: 'success') end - describe '#stages' do - subject { pipeline.stages } + describe '#stage_seeds' do + let(:pipeline) do + create(:ci_pipeline, config: { rspec: { script: 'rake' } }) + end + + it 'returns preseeded stage seeds object' do + expect(pipeline.stage_seeds).to all(be_a Gitlab::Ci::Stage::Seed) + expect(pipeline.stage_seeds.count).to eq 1 + end + end + + describe '#legacy_stages' do + subject { pipeline.legacy_stages } context 'stages list' do it 'returns ordered list of stages' do @@ -252,7 +285,7 @@ describe Ci::Pipeline, models: true do end it 'populates stage with correct number of warnings' do - deploy_stage = pipeline.stages.third + deploy_stage = pipeline.legacy_stages.third expect(deploy_stage).not_to receive(:statuses) expect(deploy_stage).to have_warnings @@ -266,22 +299,22 @@ describe Ci::Pipeline, models: true do end end - describe '#stages_name' do + describe '#stages_names' do it 'returns a valid names of stages' do - expect(pipeline.stages_name).to eq(%w(build test deploy)) + expect(pipeline.stages_names).to eq(%w(build test deploy)) end end end - describe '#stage' do - subject { pipeline.stage('test') } + describe '#legacy_stage' do + subject { pipeline.legacy_stage('test') } context 'with status in stage' do before do create(:commit_status, pipeline: pipeline, stage: 'test') end - it { expect(subject).to be_a Ci::Stage } + it { expect(subject).to be_a Ci::LegacyStage } it { expect(subject.name).to eq 'test' } it { expect(subject.statuses).not_to be_empty } end @@ -502,6 +535,20 @@ describe Ci::Pipeline, models: true do end end + describe '#has_stage_seeds?' do + context 'when pipeline has stage seeds' do + subject { build(:ci_pipeline_with_one_job) } + + it { is_expected.to have_stage_seeds } + end + + context 'when pipeline does not have stage seeds' do + subject { create(:ci_pipeline_without_jobs) } + + it { is_expected.not_to have_stage_seeds } + end + end + describe '#has_warnings?' do subject { pipeline.has_warnings? } @@ -854,6 +901,16 @@ describe Ci::Pipeline, models: true do end end end + + context 'when there is a manual action present in the pipeline' do + before do + create(:ci_build, :manual, pipeline: pipeline) + end + + it 'is not cancelable' do + expect(pipeline).not_to be_cancelable + end + end end describe '#cancel_running' do @@ -955,7 +1012,7 @@ describe Ci::Pipeline, models: true do end before do - ProjectWebHookWorker.drain + WebHookWorker.drain end context 'with pipeline hooks enabled' do @@ -1050,8 +1107,8 @@ describe Ci::Pipeline, models: true do let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master', sha: 'a288a022a53a5a944fae87bcec6efc87b7061808') } 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) allow_any_instance_of(MergeRequest).to receive(:diff_head_sha) { 'a288a022a53a5a944fae87bcec6efc87b7061808' } + merge_request = create(:merge_request, source_project: project, head_pipeline: pipeline, source_branch: pipeline.ref) expect(pipeline.merge_requests).to eq([merge_request]) end diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index 048d25869bc..077b10227d7 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Ci::Variable, models: true do - subject { Ci::Variable.new } + subject { build(:ci_variable) } let(:secret_value) { 'secret' } @@ -12,11 +12,33 @@ describe Ci::Variable, models: true do it { is_expected.not_to allow_value('foo bar').for(:key) } it { is_expected.not_to allow_value('foo/bar').for(:key) } - before :each do - subject.value = secret_value + describe '.unprotected' do + subject { described_class.unprotected } + + context 'when variable is protected' do + before do + create(:ci_variable, :protected) + end + + it 'returns nothing' do + is_expected.to be_empty + end + end + + context 'when variable is not protected' do + let(:variable) { create(:ci_variable, protected: false) } + + it 'returns the variable' do + is_expected.to contain_exactly(variable) + end + end end describe '#value' do + before do + subject.value = secret_value + end + it 'stores the encrypted value' do expect(subject.encrypted_value).not_to be_nil end @@ -36,4 +58,11 @@ describe Ci::Variable, models: true do to raise_error(OpenSSL::Cipher::CipherError, 'bad decrypt') end end + + describe '#to_runner_variable' do + it 'returns a hash for the runner' do + expect(subject.to_runner_variable) + .to eq(key: subject.key, value: subject.value, public: false) + end + end end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 852889d4540..72f83d63224 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -388,32 +388,4 @@ eos expect(described_class.valid_hash?('a' * 41)).to be false end end - - describe '#raw_diffs' do - context 'Gitaly commit_raw_diffs feature enabled' do - before do - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:commit_raw_diffs).and_return(true) - end - - context 'when a truthy deltas_only is not passed to args' do - it 'fetches diffs from Gitaly server' do - expect(Gitlab::GitalyClient::Commit).to receive(:diff_from_parent). - with(commit) - - commit.raw_diffs - end - end - - context 'when a truthy deltas_only is passed to args' do - it 'fetches diffs using Rugged' do - opts = { deltas_only: true } - - expect(Gitlab::GitalyClient::Commit).not_to receive(:diff_from_parent) - expect(commit.raw).to receive(:diffs).with(opts) - - commit.raw_diffs(opts) - end - end - end - end end diff --git a/spec/models/concerns/discussion_on_diff_spec.rb b/spec/models/concerns/discussion_on_diff_spec.rb index 8571e85627c..f3e148f95f0 100644 --- a/spec/models/concerns/discussion_on_diff_spec.rb +++ b/spec/models/concerns/discussion_on_diff_spec.rb @@ -21,4 +21,30 @@ describe DiscussionOnDiff, model: true do end end end + + describe '#line_code_in_diffs' do + context 'when the discussion is active in the diff' do + let(:diff_refs) { subject.position.diff_refs } + + it 'returns the current line code' do + expect(subject.line_code_in_diffs(diff_refs)).to eq(subject.line_code) + end + end + + context 'when the discussion was created in the diff' do + let(:diff_refs) { subject.original_position.diff_refs } + + it 'returns the original line code' do + expect(subject.line_code_in_diffs(diff_refs)).to eq(subject.original_line_code) + end + end + + context 'when the discussion is unrelated to the diff' do + let(:diff_refs) { subject.project.commit(RepoHelpers.sample_commit.id).diff_refs } + + it 'returns nil' do + expect(subject.line_code_in_diffs(diff_refs)).to be_nil + end + end + end end diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb index 2092576e981..e382c7120de 100644 --- a/spec/models/concerns/mentionable_spec.rb +++ b/spec/models/concerns/mentionable_spec.rb @@ -163,3 +163,52 @@ describe Issue, "Mentionable" do end end end + +describe Commit, 'Mentionable' do + let(:project) { create(:project, :public, :repository) } + let(:commit) { project.commit } + + describe '#matches_cross_reference_regex?' do + it "is false when message doesn't reference anything" do + allow(commit.raw).to receive(:message).and_return "WIP: Do something" + + expect(commit.matches_cross_reference_regex?).to be false + end + + it 'is true if issue #number mentioned in title' do + allow(commit.raw).to receive(:message).and_return "#1" + + expect(commit.matches_cross_reference_regex?).to be true + end + + it 'is true if references an MR' do + allow(commit.raw).to receive(:message).and_return "See merge request !12" + + expect(commit.matches_cross_reference_regex?).to be true + end + + it 'is true if references a commit' do + allow(commit.raw).to receive(:message).and_return "a1b2c3d4" + + expect(commit.matches_cross_reference_regex?).to be true + end + + it 'is true if issue referenced by url' do + issue = create(:issue, project: project) + + allow(commit.raw).to receive(:message).and_return Gitlab::UrlBuilder.build(issue) + + expect(commit.matches_cross_reference_regex?).to be true + end + + context 'with external issue tracker' do + let(:project) { create(:jira_project) } + + it 'is true if external issues referenced' do + allow(commit.raw).to receive(:message).and_return 'JIRA-123' + + expect(commit.matches_cross_reference_regex?).to be true + end + end + end +end diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb index c2ba012a0e6..fd58bd1d6ad 100644 --- a/spec/models/cycle_analytics/test_spec.rb +++ b/spec/models/cycle_analytics/test_spec.rb @@ -13,7 +13,7 @@ describe 'CycleAnalytics#test', feature: true do 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 = context.create(:ci_pipeline, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, project: context.project, head_pipeline_of: merge_request) { pipeline: pipeline, issue: issue } end, start_time_conditions: [["pipeline is started", -> (context, data) { data[:pipeline].run! }]], diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 212fcd884a8..77f04a01238 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -16,6 +16,19 @@ describe Deployment, models: true do it { is_expected.to validate_presence_of(:ref) } it { is_expected.to validate_presence_of(:sha) } + describe 'after_create callbacks' do + let(:environment) { create(:environment) } + let(:store) { Gitlab::EtagCaching::Store.new } + + it 'invalidates the environment etag cache' do + old_value = store.get(environment.etag_cache_key) + + create(:deployment, environment: environment) + + expect(store.get(environment.etag_cache_key)).not_to eq(old_value) + end + end + describe '#includes_commit?' do let(:project) { create(:project, :repository) } let(:environment) { create(:environment, project: project) } @@ -52,7 +65,11 @@ describe Deployment, models: true do describe '#metrics' do let(:deployment) { create(:deployment) } +<<<<<<< HEAD subject { deployment.metrics(1.hour) } +======= + subject { deployment.metrics } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 context 'metrics are disabled' do it { is_expected.to eq({}) } @@ -63,16 +80,29 @@ describe Deployment, models: true do { success: true, metrics: {}, +<<<<<<< HEAD last_update: 42 +======= + last_update: 42, + deployment_time: 1494408956 +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 } end before do +<<<<<<< HEAD allow(deployment.project).to receive_message_chain(:monitoring_service, :metrics) .with(any_args).and_return(simple_metrics) end it { is_expected.to eq(simple_metrics.merge(deployment_time: deployment.created_at.utc.to_i)) } +======= + allow(deployment.project).to receive_message_chain(:monitoring_service, :deployment_metrics) + .with(any_args).and_return(simple_metrics) + end + + it { is_expected.to eq(simple_metrics) } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end diff --git a/spec/models/diff_discussion_spec.rb b/spec/models/diff_discussion_spec.rb index 81f338745b1..45b2f6e4beb 100644 --- a/spec/models/diff_discussion_spec.rb +++ b/spec/models/diff_discussion_spec.rb @@ -48,7 +48,7 @@ describe DiffDiscussion, model: true do end it 'returns the diff ID for the version to show' do - expect(diff_id: merge_request_diff1.id) + expect(subject.merge_request_version_params).to eq(diff_id: merge_request_diff1.id) end end @@ -65,6 +65,11 @@ describe DiffDiscussion, model: true do let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) } + before do + diff_note.position = diff_note.original_position + diff_note.save! + end + it 'returns the diff ID and start sha of the versions to compare' do expect(subject.merge_request_version_params).to eq(diff_id: merge_request_diff3.id, start_sha: merge_request_diff1.head_commit_sha) end diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index ab4c51a87b0..297c2108dc2 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -145,7 +145,7 @@ describe DiffNote, models: true do context "when the merge request's diff refs don't match that of the diff note" do before do - allow(subject.noteable).to receive(:diff_sha_refs).and_return(commit.diff_refs) + allow(subject.noteable).to receive(:diff_refs).and_return(commit.diff_refs) end it "returns false" do @@ -160,12 +160,6 @@ describe DiffNote, models: true do context "when noteable is a commit" do let(:diff_note) { create(:diff_note_on_commit, project: project, position: position) } - it "doesn't use the DiffPositionUpdateService" do - expect(Notes::DiffPositionUpdateService).not_to receive(:new) - - diff_note - end - it "doesn't update the position" do diff_note @@ -178,12 +172,6 @@ describe DiffNote, models: true do let(:diff_note) { create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) } context "when the note is active" do - it "doesn't use the DiffPositionUpdateService" do - expect(Notes::DiffPositionUpdateService).not_to receive(:new) - - diff_note - end - it "doesn't update the position" do diff_note @@ -194,21 +182,14 @@ describe DiffNote, models: true do context "when the note is outdated" do before do - allow(merge_request).to receive(:diff_sha_refs).and_return(commit.diff_refs) + allow(merge_request).to receive(:diff_refs).and_return(commit.diff_refs) end - it "uses the DiffPositionUpdateService" do - service = instance_double("Notes::DiffPositionUpdateService") - expect(Notes::DiffPositionUpdateService).to receive(:new).with( - project, - nil, - old_diff_refs: position.diff_refs, - new_diff_refs: commit.diff_refs, - paths: [path] - ).and_return(service) - expect(service).to receive(:execute) - + it "updates the position" do diff_note + + expect(diff_note.original_position).to eq(position) + expect(diff_note.position).not_to eq(position) end end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 28e5c3f80f4..fe69c8e351d 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Environment, models: true do - let(:project) { create(:empty_project) } + set(:project) { create(:empty_project) } subject(:environment) { create(:environment, project: project) } it { is_expected.to belong_to(:project) } @@ -34,6 +34,26 @@ describe Environment, models: true do end end + describe 'state machine' do + it 'invalidates the cache after a change' do + expect(environment).to receive(:expire_etag_cache) + + environment.stop + end + end + + describe '#expire_etag_cache' do + let(:store) { Gitlab::EtagCaching::Store.new } + + it 'changes the cached value' do + old_value = store.get(environment.etag_cache_key) + + environment.stop + + expect(store.get(environment.etag_cache_key)).not_to eq(old_value) + end + end + describe '#nullify_external_url' do it 'replaces a blank url with nil' do env = build(:environment, external_url: "") @@ -227,7 +247,10 @@ describe Environment, models: true do context 'when user is allowed to stop environment' do before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end context 'when action did not yet finish' do @@ -393,7 +416,7 @@ describe Environment, models: true do it 'returns the metrics from the deployment service' do expect(project.monitoring_service) - .to receive(:metrics).with(environment) + .to receive(:environment_metrics).with(environment) .and_return(:fake_metrics) is_expected.to eq(:fake_metrics) @@ -438,7 +461,7 @@ describe Environment, models: true do "foo**bar" => "foo-bar" + SUFFIX, "*-foo" => "env-foo" + SUFFIX, "staging-12345678-" => "staging-12345678" + SUFFIX, - "staging-12345678-01234567" => "staging-12345678" + SUFFIX, + "staging-12345678-01234567" => "staging-12345678" + SUFFIX }.each do |name, matcher| it "returns a slug matching #{matcher}, given #{name}" do slug = described_class.new(name: name).generate_slug diff --git a/spec/models/forked_project_link_spec.rb b/spec/models/forked_project_link_spec.rb index 454550c9710..6e8d43f988c 100644 --- a/spec/models/forked_project_link_spec.rb +++ b/spec/models/forked_project_link_spec.rb @@ -2,8 +2,8 @@ require 'spec_helper' describe ForkedProjectLink, "add link on fork" do let(:project_from) { create(:project, :repository) } - let(:namespace) { create(:namespace) } - let(:user) { create(:user, namespace: namespace) } + let(:user) { create(:user) } + let(:namespace) { user.namespace } before do create(:project_member, :reporter, user: user, project: project_from) diff --git a/spec/models/global_milestone_spec.rb b/spec/models/global_milestone_spec.rb index 55b87d1c48a..a14efda3eda 100644 --- a/spec/models/global_milestone_spec.rb +++ b/spec/models/global_milestone_spec.rb @@ -137,7 +137,7 @@ describe GlobalMilestone, models: true do [ milestone1_project1, milestone1_project2, - milestone1_project3, + milestone1_project3 ] milestones_relation = Milestone.where(id: milestones.map(&:id)) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 91b235c267c..3634694abed 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -178,16 +178,24 @@ describe Group, models: true do describe '#avatar_url' do let!(:group) { create(:group, :access_requestable, :with_avatar) } let(:user) { create(:user) } - subject { group.avatar_url } + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } + let(:avatar_path) { "/uploads/group/avatar/#{group.id}/dk.png" } context 'when avatar file is uploaded' do - before do - group.add_master(user) - end + before { group.add_master(user) } +<<<<<<< HEAD let(:avatar_path) { "/uploads/system/group/avatar/#{group.id}/dk.png" } +======= + it 'shows correct avatar url' do + expect(group.avatar_url).to eq(avatar_path) + expect(group.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + + expect(group.avatar_url).to eq([gitlab_host, avatar_path].join) + end end end diff --git a/spec/models/hooks/service_hook_spec.rb b/spec/models/hooks/service_hook_spec.rb index 1a83c836652..57454d2a773 100644 --- a/spec/models/hooks/service_hook_spec.rb +++ b/spec/models/hooks/service_hook_spec.rb @@ -1,36 +1,19 @@ -require "spec_helper" +require 'spec_helper' describe ServiceHook, models: true do - describe "Associations" do + describe 'associations' do it { is_expected.to belong_to :service } end - describe "execute" do - before(:each) do - @service_hook = create(:service_hook) - @data = { project_id: 1, data: {} } + describe 'execute' do + let(:hook) { build(:service_hook) } + let(:data) { { key: 'value' } } - WebMock.stub_request(:post, @service_hook.url) - end - - it "POSTs to the webhook URL" do - @service_hook.execute(@data) - expect(WebMock).to have_requested(:post, @service_hook.url).with( - headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Service Hook' } - ).once - end - - it "POSTs the data as JSON" do - @service_hook.execute(@data) - expect(WebMock).to have_requested(:post, @service_hook.url).with( - headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Service Hook' } - ).once - end - - it "catches exceptions" do - expect(WebHook).to receive(:post).and_raise("Some HTTP Post error") + it '#execute' do + expect(WebHookService).to receive(:new).with(hook, data, 'service_hook').and_call_original + expect_any_instance_of(WebHookService).to receive(:execute) - expect { @service_hook.execute(@data) }.to raise_error(RuntimeError) + hook.execute(data) end end end diff --git a/spec/models/hooks/system_hook_spec.rb b/spec/models/hooks/system_hook_spec.rb index 8acec805584..0d2b622132e 100644 --- a/spec/models/hooks/system_hook_spec.rb +++ b/spec/models/hooks/system_hook_spec.rb @@ -1,6 +1,19 @@ require "spec_helper" describe SystemHook, models: true do + context 'default attributes' do + let(:system_hook) { build(:system_hook) } + + it 'sets defined default parameters' do + attrs = { + push_events: false, + repository_update_events: true, + enable_ssl_verification: true + } + expect(system_hook).to have_attributes(attrs) + end + end + describe "execute" do let(:system_hook) { create(:system_hook) } let(:user) { create(:user) } @@ -105,4 +118,34 @@ describe SystemHook, models: true do ).once end end + + describe '.repository_update_hooks' do + it 'returns hooks for repository update events only' do + hook = create(:system_hook, repository_update_events: true) + create(:system_hook, repository_update_events: false) + expect(SystemHook.repository_update_hooks).to eq([hook]) + end + end + + describe 'execute WebHookService' do + let(:hook) { build(:system_hook) } + let(:data) { { key: 'value' } } + let(:hook_name) { 'system_hook' } + + before do + expect(WebHookService).to receive(:new).with(hook, data, hook_name).and_call_original + end + + it '#execute' do + expect_any_instance_of(WebHookService).to receive(:execute) + + hook.execute(data, hook_name) + end + + it '#async_execute' do + expect_any_instance_of(WebHookService).to receive(:async_execute) + + hook.async_execute(data, hook_name) + end + end end diff --git a/spec/models/hooks/web_hook_log_spec.rb b/spec/models/hooks/web_hook_log_spec.rb new file mode 100644 index 00000000000..c649cf3b589 --- /dev/null +++ b/spec/models/hooks/web_hook_log_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +describe WebHookLog, models: true do + it { is_expected.to belong_to(:web_hook) } + + it { is_expected.to serialize(:request_headers).as(Hash) } + it { is_expected.to serialize(:request_data).as(Hash) } + it { is_expected.to serialize(:response_headers).as(Hash) } + + it { is_expected.to validate_presence_of(:web_hook) } + + describe '#success?' do + let(:web_hook_log) { build(:web_hook_log, response_status: status) } + + describe '2xx' do + let(:status) { '200' } + it { expect(web_hook_log.success?).to be_truthy } + end + + describe 'not 2xx' do + let(:status) { '500' } + it { expect(web_hook_log.success?).to be_falsey } + end + + describe 'internal erorr' do + let(:status) { 'internal error' } + it { expect(web_hook_log.success?).to be_falsey } + end + end +end diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb index 9d4db1bfb52..53157c24477 100644 --- a/spec/models/hooks/web_hook_spec.rb +++ b/spec/models/hooks/web_hook_spec.rb @@ -1,89 +1,54 @@ require 'spec_helper' describe WebHook, models: true do - describe "Validations" do + let(:hook) { build(:project_hook) } + + describe 'associations' do + it { is_expected.to have_many(:web_hook_logs).dependent(:destroy) } + end + + describe 'validations' do it { is_expected.to validate_presence_of(:url) } describe 'url' do - it { is_expected.to allow_value("http://example.com").for(:url) } - it { is_expected.to allow_value("https://example.com").for(:url) } - it { is_expected.to allow_value(" https://example.com ").for(:url) } - it { is_expected.to allow_value("http://test.com/api").for(:url) } - it { is_expected.to allow_value("http://test.com/api?key=abc").for(:url) } - it { is_expected.to allow_value("http://test.com/api?key=abc&type=def").for(:url) } + it { is_expected.to allow_value('http://example.com').for(:url) } + it { is_expected.to allow_value('https://example.com').for(:url) } + it { is_expected.to allow_value(' https://example.com ').for(:url) } + it { is_expected.to allow_value('http://test.com/api').for(:url) } + it { is_expected.to allow_value('http://test.com/api?key=abc').for(:url) } + it { is_expected.to allow_value('http://test.com/api?key=abc&type=def').for(:url) } - it { is_expected.not_to allow_value("example.com").for(:url) } - it { is_expected.not_to allow_value("ftp://example.com").for(:url) } - it { is_expected.not_to allow_value("herp-and-derp").for(:url) } + it { is_expected.not_to allow_value('example.com').for(:url) } + it { is_expected.not_to allow_value('ftp://example.com').for(:url) } + it { is_expected.not_to allow_value('herp-and-derp').for(:url) } it 'strips :url before saving it' do - hook = create(:project_hook, url: ' https://example.com ') + hook.url = ' https://example.com ' + hook.save expect(hook.url).to eq('https://example.com') end end end - describe "execute" do - let(:project) { create(:empty_project) } - let(:project_hook) { create(:project_hook) } - - before(:each) do - project.hooks << [project_hook] - @data = { before: 'oldrev', after: 'newrev', ref: 'ref' } - - WebMock.stub_request(:post, project_hook.url) - end - - context 'when token is defined' do - let(:project_hook) { create(:project_hook, :token) } - - it 'POSTs to the webhook URL' do - project_hook.execute(@data, 'push_hooks') - expect(WebMock).to have_requested(:post, project_hook.url).with( - headers: { 'Content-Type' => 'application/json', - 'X-Gitlab-Event' => 'Push Hook', - 'X-Gitlab-Token' => project_hook.token } - ).once - end - end - - it "POSTs to the webhook URL" do - project_hook.execute(@data, 'push_hooks') - expect(WebMock).to have_requested(:post, project_hook.url).with( - headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Push Hook' } - ).once - end - - it "POSTs the data as JSON" do - project_hook.execute(@data, 'push_hooks') - expect(WebMock).to have_requested(:post, project_hook.url).with( - headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Push Hook' } - ).once - end - - it "catches exceptions" do - expect(WebHook).to receive(:post).and_raise("Some HTTP Post error") - - expect { project_hook.execute(@data, 'push_hooks') }.to raise_error(RuntimeError) - end - - it "handles SSL exceptions" do - expect(WebHook).to receive(:post).and_raise(OpenSSL::SSL::SSLError.new('SSL error')) + describe 'execute' do + let(:data) { { key: 'value' } } + let(:hook_name) { 'project hook' } - expect(project_hook.execute(@data, 'push_hooks')).to eq([false, 'SSL error']) + before do + expect(WebHookService).to receive(:new).with(hook, data, hook_name).and_call_original end - it "handles 200 status code" do - WebMock.stub_request(:post, project_hook.url).to_return(status: 200, body: "Success") + it '#execute' do + expect_any_instance_of(WebHookService).to receive(:execute) - expect(project_hook.execute(@data, 'push_hooks')).to eq([200, 'Success']) + hook.execute(data, hook_name) end - it "handles 2xx status codes" do - WebMock.stub_request(:post, project_hook.url).to_return(status: 201, body: "Success") + it '#async_execute' do + expect_any_instance_of(WebHookService).to receive(:async_execute) - expect(project_hook.execute(@data, 'push_hooks')).to eq([201, 'Success']) + hook.async_execute(data, hook_name) end end end diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 7c40cfd8253..f1e2a2cc518 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -66,14 +66,16 @@ describe Key, models: true do end it "does not accept the exact same key twice" do - create(:key, user: user) - expect(build(:key, user: user)).not_to be_valid + first_key = create(:key, user: user) + + expect(build(:key, user: user, key: first_key.key)).not_to be_valid end it "does not accept a duplicate key with a different comment" do - create(:key, user: user) - duplicate = build(:key, user: user) + first_key = create(:key, user: user) + duplicate = build(:key, user: user, key: first_key.key) duplicate.key << ' extra comment' + expect(duplicate).not_to be_valid end end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 80ca19acdda..84867e3d96b 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -49,6 +49,23 @@ describe Label, models: true do expect(label.color).to eq('#abcdef') end + + it 'uses default color if color is missing' do + label = described_class.new(color: nil) + + expect(label.color).to be(Label::DEFAULT_COLOR) + end + end + + describe '#text_color' do + it 'uses default color if color is missing' do + expect(LabelsHelper).to receive(:text_color_for_bg).with(Label::DEFAULT_COLOR). + and_return(spy) + + label = described_class.new(color: nil) + + label.text_color + end end describe '#title' do diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index 0a10ee01506..ed9fde57bf7 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -139,4 +139,15 @@ describe MergeRequestDiff, models: true do expect(subject.commits_count).to eq 2 end end + + describe '#utf8_st_diffs' do + it 'does not raise error when a hash value is in binary' do + subject.st_diffs = [ + { diff: "\0" }, + { diff: "\x05\x00\x68\x65\x6c\x6c\x6f" } + ] + + expect { subject.utf8_st_diffs }.not_to raise_error + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 23cbc56cb0e..060754fab63 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -238,10 +238,10 @@ describe MergeRequest, models: true do end context 'when there are no MR diffs' do - it 'delegates to the compare object, setting no_collapse: true' do + it 'delegates to the compare object, setting expanded: true' do merge_request.compare = double(:compare) - expect(merge_request.compare).to receive(:diffs).with(options.merge(no_collapse: true)) + expect(merge_request.compare).to receive(:diffs).with(options.merge(expanded: true)) merge_request.diffs(options) end @@ -718,13 +718,7 @@ describe MergeRequest, models: true do describe '#head_pipeline' do describe 'when the source project exists' do it 'returns the latest pipeline' do - pipeline = double(:ci_pipeline, ref: 'master') - - allow(subject).to receive(:diff_head_sha).and_return('123abc') - - expect(subject.source_project).to receive(:pipeline_for). - with('master', '123abc'). - and_return(pipeline) + pipeline = create(:ci_empty_pipeline, project: subject.source_project, ref: 'master', status: 'running', sha: "123abc", head_pipeline_of: subject) expect(subject.head_pipeline).to eq(pipeline) end @@ -1184,7 +1178,7 @@ describe MergeRequest, models: true do end describe "#reload_diff" do - let(:note) { create(:diff_note_on_merge_request, project: subject.project, noteable: subject) } + let(:discussion) { create(:diff_note_on_merge_request, project: subject.project, noteable: subject).to_discussion } let(:commit) { subject.project.commit(sample_commit.id) } @@ -1203,7 +1197,7 @@ describe MergeRequest, models: true do subject.reload_diff end - it "updates diff note positions" do + it "updates diff discussion positions" do old_diff_refs = subject.diff_refs # Update merge_request_diff so that #diff_refs will return commit.diff_refs @@ -1217,18 +1211,18 @@ describe MergeRequest, models: true do subject.merge_request_diff(true) end - expect(Notes::DiffPositionUpdateService).to receive(:new).with( + expect(Discussions::UpdateDiffPositionService).to receive(:new).with( subject.project, - nil, + subject.author, old_diff_refs: old_diff_refs, new_diff_refs: commit.diff_refs, - paths: note.position.paths + paths: discussion.position.paths ).and_call_original - expect_any_instance_of(Notes::DiffPositionUpdateService).to receive(:execute).with(note) + expect_any_instance_of(Discussions::UpdateDiffPositionService).to receive(:execute).with(discussion).and_call_original expect_any_instance_of(DiffNote).to receive(:save).once - subject.reload_diff + subject.reload_diff(subject.author) end end @@ -1249,7 +1243,7 @@ describe MergeRequest, models: true do end end - describe "#diff_sha_refs" do + describe "#diff_refs" do context "with diffs" do subject { create(:merge_request, :with_diffs) } @@ -1258,7 +1252,7 @@ describe MergeRequest, models: true do expect_any_instance_of(Repository).not_to receive(:commit) - subject.diff_sha_refs + subject.diff_refs end it "returns expected diff_refs" do @@ -1268,7 +1262,7 @@ describe MergeRequest, models: true do head_sha: subject.merge_request_diff.head_commit_sha ) - expect(subject.diff_sha_refs).to eq(expected_diff_refs) + expect(subject.diff_refs).to eq(expected_diff_refs) end end end @@ -1397,11 +1391,14 @@ describe MergeRequest, models: true do describe '#mergeable_with_slash_command?' do def create_pipeline(status) - create(:ci_pipeline_with_one_job, + pipeline = create(:ci_pipeline_with_one_job, project: project, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, - status: status) + status: status, + head_pipeline_of: merge_request) + + pipeline end let(:project) { create(:project, :public, :repository, only_allow_merge_if_pipeline_succeeds: true) } @@ -1537,4 +1534,36 @@ describe MergeRequest, models: true do end end end + + describe '#version_params_for' do + subject { create(:merge_request, importing: true) } + let(:project) { subject.project } + let!(:merge_request_diff1) { subject.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } + let!(:merge_request_diff2) { subject.merge_request_diffs.create(head_commit_sha: nil) } + let!(:merge_request_diff3) { subject.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') } + + context 'when the diff refs are for an older merge request version' do + let(:diff_refs) { merge_request_diff1.diff_refs } + + it 'returns the diff ID for the version to show' do + expect(subject.version_params_for(diff_refs)).to eq(diff_id: merge_request_diff1.id) + end + end + + context 'when the diff refs are for a comparison between merge request versions' do + let(:diff_refs) { merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs } + + it 'returns the diff ID and start sha of the versions to compare' do + expect(subject.version_params_for(diff_refs)).to eq(diff_id: merge_request_diff3.id, start_sha: merge_request_diff1.head_commit_sha) + end + end + + context 'when the diff refs are not for a merge request version' do + let(:diff_refs) { project.commit(sample_commit.id).diff_refs } + + it 'returns nil' do + expect(subject.version_params_for(diff_refs)).to be_nil + end + end + end end diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index e3e8e6d571c..aa1ce89ffd7 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -249,4 +249,17 @@ describe Milestone, models: true do expect(milestone.to_reference(another_project)).to eq "sample-project%1" end end + + describe '#participants' do + let(:project) { build(:empty_project, name: 'sample-project') } + let(:milestone) { build(:milestone, iid: 1, project: project) } + + it 'returns participants without duplicates' do + user = create :user + create :issue, project: project, milestone: milestone, assignees: [user] + create :issue, project: project, milestone: milestone, assignees: [user] + + expect(milestone.participants).to eq [user] + end + end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 38179c60af4..145c7ad5770 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -244,8 +244,8 @@ describe Namespace, models: true do end context 'in sub-groups' do - let(:parent) { create(:namespace, path: 'parent') } - let(:child) { create(:namespace, parent: parent, path: 'child') } + let(:parent) { create(:group, path: 'parent') } + let(:child) { create(:group, parent: parent, path: 'child') } let!(:project) { create(:project_empty_repo, namespace: child) } let(:path_in_dir) { File.join(repository_storage_path, 'parent', 'child') } let(:deleted_path) { File.join('parent', "child+#{child.id}+deleted") } diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index c6c45d78990..f9d060d4e0e 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -6,7 +6,7 @@ describe PagesDomain, models: true do end describe 'validate domain' do - subject { build(:pages_domain, domain: domain) } + subject(:pages_domain) { build(:pages_domain, domain: domain) } context 'is unique' do let(:domain) { 'my.domain.com' } @@ -14,36 +14,25 @@ describe PagesDomain, models: true do it { is_expected.to validate_uniqueness_of(:domain) } end - context 'valid domain' do - let(:domain) { 'my.domain.com' } - - it { is_expected.to be_valid } - end - - context 'valid hexadecimal-looking domain' do - let(:domain) { '0x12345.com'} - - it { is_expected.to be_valid } - end - - context 'no domain' do - let(:domain) { nil } - - it { is_expected.not_to be_valid } - end - - context 'invalid domain' do - let(:domain) { '0123123' } - - it { is_expected.not_to be_valid } - end - - context 'domain from .example.com' do - let(:domain) { 'my.domain.com' } - - before { allow(Settings.pages).to receive(:host).and_return('domain.com') } - - it { is_expected.not_to be_valid } + { + 'my.domain.com' => true, + '123.456.789' => true, + '0x12345.com' => true, + '0123123' => true, + '_foo.com' => false, + 'reserved.com' => false, + 'a.reserved.com' => false, + nil => false + }.each do |value, validity| + context "domain #{value.inspect} validity" do + before do + allow(Settings.pages).to receive(:host).and_return('reserved.com') + end + + let(:domain) { value } + + it { expect(pages_domain.valid?).to eq(validity) } + end end end diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb index 823623d96fa..fa781195608 100644 --- a/spec/models/personal_access_token_spec.rb +++ b/spec/models/personal_access_token_spec.rb @@ -35,6 +35,16 @@ describe PersonalAccessToken, models: true do end end + describe 'revoke!' do + let(:active_personal_access_token) { create(:personal_access_token) } + + it 'revokes the token' do + active_personal_access_token.revoke! + + expect(active_personal_access_token.revoked?).to be true + end + end + context "validations" do let(:personal_access_token) { build(:personal_access_token) } @@ -51,11 +61,17 @@ describe PersonalAccessToken, models: true do expect(personal_access_token).to be_valid end - it "rejects creating a token with non-API scopes" do + it "allows creating a token with read_registry scope" do + personal_access_token.scopes = [:read_registry] + + expect(personal_access_token).to be_valid + end + + it "rejects creating a token with unavailable scopes" do personal_access_token.scopes = [:openid, :api] expect(personal_access_token).not_to be_valid - expect(personal_access_token.errors[:scopes].first).to eq "can only contain API scopes" + expect(personal_access_token.errors[:scopes].first).to eq "can only contain available scopes" end end end diff --git a/spec/models/project_authorization_spec.rb b/spec/models/project_authorization_spec.rb index 33ef67f97a7..cd0a4a94809 100644 --- a/spec/models/project_authorization_spec.rb +++ b/spec/models/project_authorization_spec.rb @@ -16,7 +16,7 @@ describe ProjectAuthorization do it 'inserts rows in batches' do described_class.insert_authorizations([ [user.id, project1.id, Gitlab::Access::MASTER], - [user.id, project2.id, Gitlab::Access::MASTER], + [user.id, project2.id, Gitlab::Access::MASTER] ], 1) expect(user.project_authorizations.count).to eq(2) diff --git a/spec/models/project_services/asana_service_spec.rb b/spec/models/project_services/asana_service_spec.rb index 48aef3a93f2..95c35162d96 100644 --- a/spec/models/project_services/asana_service_spec.rb +++ b/spec/models/project_services/asana_service_spec.rb @@ -28,7 +28,7 @@ describe AsanaService, models: true do commits: messages.map do |m| { message: m, - url: 'https://gitlab.com/', + url: 'https://gitlab.com/' } end } diff --git a/spec/models/project_services/chat_message/issue_message_spec.rb b/spec/models/project_services/chat_message/issue_message_spec.rb index 34e2d94b1ed..c159ab00ab1 100644 --- a/spec/models/project_services/chat_message/issue_message_spec.rb +++ b/spec/models/project_services/chat_message/issue_message_spec.rb @@ -48,7 +48,7 @@ describe ChatMessage::IssueMessage, models: true do title: "#100 Issue title", title_link: "http://url.com", text: "issue description", - color: color, + color: color } ]) end diff --git a/spec/models/project_services/chat_message/merge_message_spec.rb b/spec/models/project_services/chat_message/merge_message_spec.rb index fa0a1f4a5b7..61f17031172 100644 --- a/spec/models/project_services/chat_message/merge_message_spec.rb +++ b/spec/models/project_services/chat_message/merge_message_spec.rb @@ -22,7 +22,7 @@ describe ChatMessage::MergeMessage, models: true do state: 'opened', description: 'merge request description', source_branch: 'source_branch', - target_branch: 'target_branch', + target_branch: 'target_branch' } } end diff --git a/spec/models/project_services/chat_message/note_message_spec.rb b/spec/models/project_services/chat_message/note_message_spec.rb index 7cd9c61ee2b..7996536218a 100644 --- a/spec/models/project_services/chat_message/note_message_spec.rb +++ b/spec/models/project_services/chat_message/note_message_spec.rb @@ -15,7 +15,7 @@ describe ChatMessage::NoteMessage, models: true do project_url: 'http://somewhere.com', repository: { name: 'project_name', - url: 'http://somewhere.com', + url: 'http://somewhere.com' }, object_attributes: { id: 10, diff --git a/spec/models/project_services/chat_message/pipeline_message_spec.rb b/spec/models/project_services/chat_message/pipeline_message_spec.rb index e005be42b0d..7d2599dc703 100644 --- a/spec/models/project_services/chat_message/pipeline_message_spec.rb +++ b/spec/models/project_services/chat_message/pipeline_message_spec.rb @@ -62,7 +62,7 @@ describe ChatMessage::PipelineMessage do def build_message(status_text = status, name = user[:name]) "<http://example.gitlab.com|project_name>:" \ " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ - " of <http://example.gitlab.com/commits/develop|develop> branch" \ + " of branch `<http://example.gitlab.com/commits/develop|develop>`" \ " by #{name} #{status_text} in 02:00:10" end end @@ -81,7 +81,7 @@ describe ChatMessage::PipelineMessage do expect(subject.pretext).to be_empty expect(subject.attachments).to eq(message) expect(subject.activity).to eq({ - title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker passed', + title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of branch `[develop](http://example.gitlab.com/commits/develop)` by hacker passed', subtitle: 'in [project_name](http://example.gitlab.com)', text: 'in 02:00:10', image: '' @@ -98,7 +98,7 @@ describe ChatMessage::PipelineMessage do expect(subject.pretext).to be_empty expect(subject.attachments).to eq(message) expect(subject.activity).to eq({ - title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker failed', + title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of branch `[develop](http://example.gitlab.com/commits/develop)` by hacker failed', subtitle: 'in [project_name](http://example.gitlab.com)', text: 'in 02:00:10', image: '' @@ -113,7 +113,7 @@ describe ChatMessage::PipelineMessage do expect(subject.pretext).to be_empty expect(subject.attachments).to eq(message) expect(subject.activity).to eq({ - title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by API failed', + title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of branch `[develop](http://example.gitlab.com/commits/develop)` by API failed', subtitle: 'in [project_name](http://example.gitlab.com)', text: 'in 02:00:10', image: '' @@ -125,8 +125,8 @@ describe ChatMessage::PipelineMessage do def build_markdown_message(status_text = status, name = user[:name]) "[project_name](http://example.gitlab.com):" \ " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of [develop](http://example.gitlab.com/commits/develop)" \ - " branch by #{name} #{status_text} in 02:00:10" + " of branch `[develop](http://example.gitlab.com/commits/develop)`" \ + " by #{name} #{status_text} in 02:00:10" end end end diff --git a/spec/models/project_services/chat_message/push_message_spec.rb b/spec/models/project_services/chat_message/push_message_spec.rb index 63eb078c44e..e38117b75f6 100644 --- a/spec/models/project_services/chat_message/push_message_spec.rb +++ b/spec/models/project_services/chat_message/push_message_spec.rb @@ -21,19 +21,19 @@ describe ChatMessage::PushMessage, models: true do before do args[:commits] = [ { message: 'message1', url: 'http://url1.com', id: 'abcdefghijkl', author: { name: 'author1' } }, - { message: 'message2', url: 'http://url2.com', id: '123456789012', author: { name: 'author2' } }, + { message: 'message2', url: 'http://url2.com', id: '123456789012', author: { name: 'author2' } } ] end context 'without markdown' do it 'returns a message regarding pushes' do expect(subject.pretext).to eq( - 'test.user pushed to branch <http://url.com/commits/master|master> of '\ + 'test.user pushed to branch `<http://url.com/commits/master|master>` of '\ '<http://url.com|project_name> (<http://url.com/compare/before...after|Compare changes>)') expect(subject.attachments).to eq([{ text: "<http://url1.com|abcdefgh>: message1 - author1\n\n"\ "<http://url2.com|12345678>: message2 - author2", - color: color, + color: color }]) end end @@ -45,7 +45,7 @@ describe ChatMessage::PushMessage, models: true do it 'returns a message regarding pushes' do expect(subject.pretext).to eq( - 'test.user pushed to branch [master](http://url.com/commits/master) of [project_name](http://url.com) ([Compare changes](http://url.com/compare/before...after))') + 'test.user pushed to branch `[master](http://url.com/commits/master)` of [project_name](http://url.com) ([Compare changes](http://url.com/compare/before...after))') expect(subject.attachments).to eq( "[abcdefgh](http://url1.com): message1 - author1\n\n[12345678](http://url2.com): message2 - author2") expect(subject.activity).to eq({ @@ -74,7 +74,7 @@ describe ChatMessage::PushMessage, models: true do context 'without markdown' do it 'returns a message regarding pushes' do expect(subject.pretext).to eq('test.user pushed new tag ' \ - '<http://url.com/commits/new_tag|new_tag> to ' \ + '`<http://url.com/commits/new_tag|new_tag>` to ' \ '<http://url.com|project_name>') expect(subject.attachments).to be_empty end @@ -87,7 +87,7 @@ describe ChatMessage::PushMessage, models: true do it 'returns a message regarding pushes' do expect(subject.pretext).to eq( - 'test.user pushed new tag [new_tag](http://url.com/commits/new_tag) to [project_name](http://url.com)') + 'test.user pushed new tag `[new_tag](http://url.com/commits/new_tag)` to [project_name](http://url.com)') expect(subject.attachments).to be_empty expect(subject.activity).to eq({ title: 'test.user created tag', @@ -107,7 +107,7 @@ describe ChatMessage::PushMessage, models: true do context 'without markdown' do it 'returns a message regarding a new branch' do expect(subject.pretext).to eq( - 'test.user pushed new branch <http://url.com/commits/master|master> to '\ + 'test.user pushed new branch `<http://url.com/commits/master|master>` to '\ '<http://url.com|project_name>') expect(subject.attachments).to be_empty end @@ -120,7 +120,7 @@ describe ChatMessage::PushMessage, models: true do it 'returns a message regarding a new branch' do expect(subject.pretext).to eq( - 'test.user pushed new branch [master](http://url.com/commits/master) to [project_name](http://url.com)') + 'test.user pushed new branch `[master](http://url.com/commits/master)` to [project_name](http://url.com)') expect(subject.attachments).to be_empty expect(subject.activity).to eq({ title: 'test.user created branch', @@ -140,7 +140,7 @@ describe ChatMessage::PushMessage, models: true do context 'without markdown' do it 'returns a message regarding a removed branch' do expect(subject.pretext).to eq( - 'test.user removed branch master from <http://url.com|project_name>') + 'test.user removed branch `master` from <http://url.com|project_name>') expect(subject.attachments).to be_empty end end @@ -152,7 +152,7 @@ describe ChatMessage::PushMessage, models: true do it 'returns a message regarding a removed branch' do expect(subject.pretext).to eq( - 'test.user removed branch master from [project_name](http://url.com)') + 'test.user removed branch `master` from [project_name](http://url.com)') expect(subject.attachments).to be_empty expect(subject.activity).to eq({ title: 'test.user removed branch', diff --git a/spec/models/project_services/chat_message/wiki_page_message_spec.rb b/spec/models/project_services/chat_message/wiki_page_message_spec.rb index 0df7db2abc2..4ca1b8aa7b7 100644 --- a/spec/models/project_services/chat_message/wiki_page_message_spec.rb +++ b/spec/models/project_services/chat_message/wiki_page_message_spec.rb @@ -53,7 +53,7 @@ describe ChatMessage::WikiPageMessage, models: true do expect(subject.attachments).to eq([ { text: "Wiki page description", - color: color, + color: color } ]) end @@ -66,7 +66,7 @@ describe ChatMessage::WikiPageMessage, models: true do expect(subject.attachments).to eq([ { text: "Wiki page description", - color: color, + color: color } ]) end diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 4bca0229e7a..0ee050196e4 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -22,49 +22,50 @@ describe JiraService, models: true do it { is_expected.not_to validate_presence_of(:url) } end - end - describe '#reference_pattern' do - it_behaves_like 'allows project key on reference pattern' + context 'validating urls' do + let(:service) do + described_class.new( + project: create(:empty_project), + active: true, + username: 'username', + password: 'test', + project_key: 'TEST', + jira_issue_transition_id: 24, + url: 'http://jira.test.com' + ) + end - it 'does not allow # on the code' do - expect(subject.reference_pattern.match('#123')).to be_nil - expect(subject.reference_pattern.match('1#23#12')).to be_nil - end - end + it 'is valid when all fields have required values' do + expect(service).to be_valid + end - describe '#can_test?' do - let(:jira_service) { described_class.new } + it 'is not valid when url is not a valid url' do + service.url = 'not valid' - it 'returns false if username is blank' do - allow(jira_service).to receive_messages( - url: 'http://jira.example.com', - username: '', - password: '12345678' - ) + expect(service).not_to be_valid + end - expect(jira_service.can_test?).to be_falsy - end + it 'is not valid when api url is not a valid url' do + service.api_url = 'not valid' - it 'returns false if password is blank' do - allow(jira_service).to receive_messages( - url: 'http://jira.example.com', - username: 'tester', - password: '' - ) + expect(service).not_to be_valid + end + + it 'is valid when api url is a valid url' do + service.api_url = 'http://jira.test.com/api' - expect(jira_service.can_test?).to be_falsy + expect(service).to be_valid + end end + end - it 'returns true if password and username are present' do - jira_service = described_class.new - allow(jira_service).to receive_messages( - url: 'http://jira.example.com', - username: 'tester', - password: '12345678' - ) + describe '#reference_pattern' do + it_behaves_like 'allows project key on reference pattern' - expect(jira_service.can_test?).to be_truthy + it 'does not allow # on the code' do + expect(subject.reference_pattern.match('#123')).to be_nil + expect(subject.reference_pattern.match('1#23#12')).to be_nil end end @@ -97,6 +98,7 @@ describe JiraService, models: true do allow(JIRA::Resource::Issue).to receive(:find).and_return(open_issue, closed_issue) allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return("JIRA-123") + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) @jira_service.save @@ -187,22 +189,29 @@ describe JiraService, models: true do describe '#test_settings' do let(:jira_service) do described_class.new( + project: create(:project), url: 'http://jira.example.com', - username: 'gitlab_jira_username', - password: 'gitlab_jira_password', + username: 'jira_username', + password: 'jira_password', project_key: 'GitLabProject' ) end - let(:project_url) { 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/project/GitLabProject' } - before do + def test_settings(api_url) + project_url = "http://jira_username:jira_password@#{api_url}/rest/api/2/project/GitLabProject" + WebMock.stub_request(:get, project_url) - end - it 'tries to get JIRA project' do jira_service.test_settings + end - expect(WebMock).to have_requested(:get, project_url) + it 'tries to get JIRA project with URL when API URL not set' do + test_settings('jira.example.com') + end + + it 'tries to get JIRA project with API URL if set' do + jira_service.update(api_url: 'http://jira.api.com') + test_settings('jira.api.com') end end @@ -214,34 +223,75 @@ describe JiraService, models: true do @jira_service = JiraService.create!( project: project, properties: { - url: 'http://jira.example.com/rest/api/2', + url: 'http://jira.example.com/web', username: 'mic', password: "password" } ) end - it "reset password if url changed" do - @jira_service.url = 'http://jira_edited.example.com/rest/api/2' - @jira_service.save - expect(@jira_service.password).to be_nil + context 'when only web url present' do + it 'reset password if url changed' do + @jira_service.url = 'http://jira_edited.example.com/rest/api/2' + @jira_service.save + + expect(@jira_service.password).to be_nil + end + + it 'reset password if url not changed but api url added' do + @jira_service.api_url = 'http://jira_edited.example.com/rest/api/2' + @jira_service.save + + expect(@jira_service.password).to be_nil + end end - it "does not reset password if username changed" do - @jira_service.username = "some_name" + context 'when both web and api url present' do + before do + @jira_service.api_url = 'http://jira.example.com/rest/api/2' + @jira_service.password = 'password' + + @jira_service.save + end + it 'reset password if api url changed' do + @jira_service.api_url = 'http://jira_edited.example.com/rest/api/2' + @jira_service.save + + expect(@jira_service.password).to be_nil + end + + it 'does not reset password if url changed' do + @jira_service.url = 'http://jira_edited.example.com/rweb' + @jira_service.save + + expect(@jira_service.password).to eq("password") + end + + it 'reset password if api url set to ""' do + @jira_service.api_url = '' + @jira_service.save + + expect(@jira_service.password).to be_nil + end + end + + it 'does not reset password if username changed' do + @jira_service.username = 'some_name' @jira_service.save - expect(@jira_service.password).to eq("password") + + expect(@jira_service.password).to eq('password') end - it "does not reset password if new url is set together with password, even if it's the same password" do + it 'does not reset password if new url is set together with password, even if it\'s the same password' do @jira_service.url = 'http://jira_edited.example.com/rest/api/2' @jira_service.password = 'password' @jira_service.save - expect(@jira_service.password).to eq("password") - expect(@jira_service.url).to eq("http://jira_edited.example.com/rest/api/2") + + expect(@jira_service.password).to eq('password') + expect(@jira_service.url).to eq('http://jira_edited.example.com/rest/api/2') end - it "resets 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.url = 'http://jira1.example.com/rest/api/2' @jira_service.url = 'http://jira1.example.com/rest/api/2' @jira_service.save @@ -249,7 +299,7 @@ describe JiraService, models: true do end end - context "when no password was previously set" do + context 'when no password was previously set' do before do @jira_service = JiraService.create( project: project, @@ -260,26 +310,16 @@ describe JiraService, models: true do ) end - it "saves password if new url is set together with password" do + it 'saves password if new url is set together with password' do @jira_service.url = 'http://jira_edited.example.com/rest/api/2' @jira_service.password = 'password' @jira_service.save - expect(@jira_service.password).to eq("password") - expect(@jira_service.url).to eq("http://jira_edited.example.com/rest/api/2") + expect(@jira_service.password).to eq('password') + expect(@jira_service.url).to eq('http://jira_edited.example.com/rest/api/2') end end end - describe "Validations" do - context "active" do - before do - subject.active = true - end - - it { is_expected.to validate_presence_of :url } - end - end - describe 'description and title' do let(:project) { create(:empty_project) } @@ -321,9 +361,10 @@ describe JiraService, models: true do context 'when gitlab.yml was initialized' do before do settings = { - "jira" => { - "title" => "Jira", - "url" => "http://jira.sample/projects/project_a" + 'jira' => { + 'title' => 'Jira', + 'url' => 'http://jira.sample/projects/project_a', + 'api_url' => 'http://jira.sample/api' } } allow(Gitlab.config).to receive(:issues_tracker).and_return(settings) @@ -335,8 +376,9 @@ describe JiraService, models: true do end it 'is prepopulated with the settings' do - expect(@service.properties["title"]).to eq('Jira') - expect(@service.properties["url"]).to eq('http://jira.sample/projects/project_a') + expect(@service.properties['title']).to eq('Jira') + expect(@service.properties['url']).to eq('http://jira.sample/projects/project_a') + expect(@service.properties['api_url']).to eq('http://jira.sample/api') end end end diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index e69eb0098dd..0dcf4a4b5d6 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -13,7 +13,7 @@ describe KubernetesService, models: true, caching: true do let(:discovery_url) { service.api_url + '/api/v1' } let(:discovery_response) { { body: kube_discovery_body.to_json } } - let(:pods_url) { service.api_url + "/api/v1/namespaces/#{service.namespace}/pods" } + let(:pods_url) { service.api_url + "/api/v1/namespaces/#{service.actual_namespace}/pods" } let(:pods_response) { { body: kube_pods_body(kube_pod).to_json } } def stub_kubeclient_discover @@ -54,7 +54,7 @@ describe KubernetesService, models: true, caching: true do 'a' * 63 => true, 'a' * 64 => false, 'a.b' => false, - 'a*b' => false, + 'a*b' => false }.each do |namespace, validity| it "validates #{namespace} as #{validity ? 'valid' : 'invalid'}" do subject.namespace = namespace @@ -100,7 +100,35 @@ describe KubernetesService, models: true, caching: true do it 'sets the namespace to the default' do expect(kube_namespace).not_to be_nil - expect(kube_namespace[:placeholder]).to match(/\A#{Gitlab::Regex::PATH_REGEX_STR}-\d+\z/) + expect(kube_namespace[:placeholder]).to match(/\A#{Gitlab::PathRegex::PATH_REGEX_STR}-\d+\z/) + end + end + end + + describe '#actual_namespace' do + subject { service.actual_namespace } + + it "returns the default namespace" do + is_expected.to eq(service.send(:default_namespace)) + end + + context 'when namespace is specified' do + before do + service.namespace = 'my-namespace' + end + + it "returns the user-namespace" do + is_expected.to eq('my-namespace') + end + end + + context 'when service is not assigned to project' do + before do + service.project = nil + end + + it "does not return namespace" do + is_expected.to be_nil end end end @@ -168,7 +196,7 @@ describe KubernetesService, models: true, caching: true do { key: 'KUBE_TOKEN', value: 'token', public: false }, { key: 'KUBE_NAMESPACE', value: 'my-project', public: true }, { key: 'KUBE_CA_PEM', value: 'CA PEM DATA', public: true }, - { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true }, + { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true } ) end end @@ -179,7 +207,7 @@ describe KubernetesService, models: true, caching: true do { key: 'KUBE_URL', value: 'https://kube.domain.com', public: true }, { key: 'KUBE_TOKEN', value: 'token', public: false }, { key: 'KUBE_CA_PEM', value: 'CA PEM DATA', public: true }, - { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true }, + { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true } ) end @@ -187,13 +215,14 @@ describe KubernetesService, models: true, caching: true do kube_namespace = subject.predefined_variables.find { |h| h[:key] == 'KUBE_NAMESPACE' } expect(kube_namespace).not_to be_nil - expect(kube_namespace[:value]).to match(/\A#{Gitlab::Regex::PATH_REGEX_STR}-\d+\z/) + expect(kube_namespace[:value]).to match(/\A#{Gitlab::PathRegex::PATH_REGEX_STR}-\d+\z/) end end end describe '#terminals' do let(:environment) { build(:environment, project: project, name: "env", slug: "env-000000") } + subject { service.terminals(environment) } context 'with invalid pods' do diff --git a/spec/models/project_services/pivotaltracker_service_spec.rb b/spec/models/project_services/pivotaltracker_service_spec.rb index 45b2f1068bf..a76e909d04d 100644 --- a/spec/models/project_services/pivotaltracker_service_spec.rb +++ b/spec/models/project_services/pivotaltracker_service_spec.rb @@ -40,7 +40,7 @@ describe PivotaltrackerService, models: true do name: 'Some User' }, url: 'https://example.com/commit', - message: 'commit message', + message: 'commit message' } ] } diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index 82a3e2698c1..42c80ca0773 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -6,6 +6,7 @@ describe PrometheusService, models: true, caching: true do let(:project) { create(:prometheus_project) } let(:service) { project.prometheus_service } + let(:environment_query) { Gitlab::Prometheus::Queries::EnvironmentQuery } describe "Associations" do it { is_expected.to belong_to :project } @@ -45,13 +46,14 @@ describe PrometheusService, models: true, caching: true do end end - describe '#metrics' do + describe '#environment_metrics' do let(:environment) { build_stubbed(:environment, slug: 'env-slug') } around do |example| Timecop.freeze { example.run } end +<<<<<<< HEAD context 'with valid data without time range' do subject { service.metrics(environment) } @@ -71,6 +73,13 @@ describe PrometheusService, models: true, caching: true do before do stub_reactive_cache(service, prometheus_data, 'env-slug', t_start, t_end) +======= + context 'with valid data' do + subject { service.environment_metrics(environment) } + + before do + stub_reactive_cache(service, prometheus_data, environment_query, environment.id) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end it 'returns reactive data' do @@ -79,15 +88,40 @@ describe PrometheusService, models: true, caching: true do end end + describe '#deployment_metrics' do + let(:deployment) { build_stubbed(:deployment)} + let(:deployment_query) { Gitlab::Prometheus::Queries::DeploymentQuery } + + around do |example| + Timecop.freeze { example.run } + end + + context 'with valid data' do + subject { service.deployment_metrics(deployment) } + + before do + stub_reactive_cache(service, prometheus_data, deployment_query, deployment.id) + end + + it 'returns reactive data' do + is_expected.to eq(prometheus_data.merge(deployment_time: deployment.created_at.to_i)) + end + end + end + describe '#calculate_reactive_cache' do - let(:environment) { build_stubbed(:environment, slug: 'env-slug') } + let(:environment) { create(:environment, slug: 'env-slug') } around do |example| Timecop.freeze { example.run } end subject do +<<<<<<< HEAD service.calculate_reactive_cache(environment.slug, nil, nil) +======= + service.calculate_reactive_cache(environment_query.to_s, environment.id) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end context 'when service is inactive' do diff --git a/spec/models/project_snippet_spec.rb b/spec/models/project_snippet_spec.rb index d9d7c0b0aaa..5fe4885eeb4 100644 --- a/spec/models/project_snippet_spec.rb +++ b/spec/models/project_snippet_spec.rb @@ -5,9 +5,6 @@ describe ProjectSnippet, models: true do it { is_expected.to belong_to(:project) } end - describe "Mass assignment" do - end - describe "Validation" do it { is_expected.to validate_presence_of(:project) } end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 692f28ea5e7..2a4917e066c 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -50,7 +50,7 @@ describe Project, models: true do 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(:statistics).class_name('ProjectStatistics').dependent(:delete) } - it { is_expected.to have_one(:import_data).class_name('ProjectImportData').dependent(:destroy) } + it { is_expected.to have_one(:import_data).class_name('ProjectImportData').dependent(:delete) } 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) } @@ -812,9 +812,21 @@ describe Project, models: true do context 'when avatar file is uploaded' do let(:project) { create(:empty_project, :with_avatar) } +<<<<<<< HEAD let(:avatar_path) { "/uploads/system/project/avatar/#{project.id}/dk.png" } +======= + let(:avatar_path) { "/uploads/project/avatar/#{project.id}/dk.png" } + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 - it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + it 'shows correct url' do + expect(project.avatar_url).to eq(avatar_path) + expect(project.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join) + + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + + expect(project.avatar_url).to eq([gitlab_host, avatar_path].join) + end end context 'When avatar file in git' do @@ -940,6 +952,20 @@ describe Project, models: true do end end + describe '.starred_by' do + it 'returns only projects starred by the given user' do + user1 = create(:user) + user2 = create(:user) + project1 = create(:empty_project) + project2 = create(:empty_project) + create(:empty_project) + user1.toggle_star(project1) + user2.toggle_star(project2) + + expect(Project.starred_by(user1)).to contain_exactly(project1) + end + end + describe '.visible_to_user' do let!(:project) { create(:empty_project, :private) } let!(:user) { create(:user) } @@ -965,7 +991,7 @@ describe Project, models: true do before do storages = { 'default' => { 'path' => 'tmp/tests/repositories' }, - 'picked' => { 'path' => 'tmp/tests/repositories' }, + 'picked' => { 'path' => 'tmp/tests/repositories' } } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end @@ -1423,6 +1449,27 @@ describe Project, models: true do end end + describe 'Project import job' do + let(:project) { create(:empty_project, import_url: generate(:url)) } + + before do + allow_any_instance_of(Gitlab::Shell).to receive(:import_repository) + .with(project.repository_storage_path, project.path_with_namespace, project.import_url) + .and_return(true) + + expect_any_instance_of(Repository).to receive(:after_import) + .and_call_original + end + + it 'imports a project' do + expect_any_instance_of(RepositoryImportWorker).to receive(:perform).and_call_original + + project.import_schedule + + expect(project.reload.import_status).to eq('finished') + end + end + describe '#latest_successful_builds_for' do def create_pipeline(status = 'success') create(:ci_pipeline, project: project, @@ -1504,7 +1551,7 @@ describe Project, models: true do describe '#add_import_job' do context 'forked' do - let(:forked_project_link) { create(:forked_project_link) } + let(:forked_project_link) { create(:forked_project_link, :forked_to_empty_project) } let(:forked_from_project) { forked_project_link.forked_from_project } let(:project) { forked_project_link.forked_to_project } @@ -1518,9 +1565,9 @@ describe Project, models: true do end context 'not forked' do - let(:project) { create(:empty_project) } - it 'schedules a RepositoryImportWorker job' do + project = create(:empty_project, import_url: generate(:url)) + expect(RepositoryImportWorker).to receive(:perform_async).with(project.id) project.add_import_job @@ -1702,6 +1749,90 @@ describe Project, models: true do end end + describe '#secret_variables_for' do + let(:project) { create(:empty_project) } + + let!(:secret_variable) do + create(:ci_variable, value: 'secret', project: project) + end + + let!(:protected_variable) do + create(:ci_variable, :protected, value: 'protected', project: project) + end + + subject { project.secret_variables_for('ref') } + + shared_examples 'ref is protected' do + it 'contains all the variables' do + is_expected.to contain_exactly(secret_variable, protected_variable) + end + end + + context 'when the ref is not protected' do + before do + stub_application_setting( + default_branch_protection: Gitlab::Access::PROTECTION_NONE) + end + + it 'contains only the secret variables' do + is_expected.to contain_exactly(secret_variable) + end + end + + context 'when the ref is a protected branch' do + before do + create(:protected_branch, name: 'ref', project: project) + end + + it_behaves_like 'ref is protected' + end + + context 'when the ref is a protected tag' do + before do + create(:protected_tag, name: 'ref', project: project) + end + + it_behaves_like 'ref is protected' + end + end + + describe '#protected_for?' do + let(:project) { create(:empty_project) } + + subject { project.protected_for?('ref') } + + context 'when the ref is not protected' do + before do + stub_application_setting( + default_branch_protection: Gitlab::Access::PROTECTION_NONE) + end + + it 'returns false' do + is_expected.to be_falsey + end + end + + context 'when the ref is a protected branch' do + before do + create(:protected_branch, name: 'ref', project: project) + end + + it 'returns true' do + is_expected.to be_truthy + end + end + + context 'when the ref is a protected tag' do + before do + create(:protected_tag, name: 'ref', project: project) + end + + it 'returns true' do + is_expected.to be_truthy + end + end + end + describe '#update_project_statistics' do let(:project) { create(:empty_project) } @@ -1876,19 +2007,9 @@ describe Project, models: true do describe '#http_url_to_repo' do let(:project) { create :empty_project } - context 'when no user is given' do - it 'returns the url to the repo without a username' do - expect(project.http_url_to_repo).to eq("#{project.web_url}.git") - expect(project.http_url_to_repo).not_to include('@') - end - end - - context 'when user is given' do - it 'returns the url to the repo with the username' do - user = build_stubbed(:user) - - expect(project.http_url_to_repo(user)).to start_with("http://#{user.username}@") - end + it 'returns the url to the repo without a username' do + expect(project.http_url_to_repo).to eq("#{project.web_url}.git") + expect(project.http_url_to_repo).not_to include('@') end end diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb index ff29f6f66ba..c5ffbda9821 100644 --- a/spec/models/project_statistics_spec.rb +++ b/spec/models/project_statistics_spec.rb @@ -35,7 +35,7 @@ describe ProjectStatistics, models: true do commit_count: 8.exabytes - 1, repository_size: 2.exabytes, lfs_objects_size: 2.exabytes, - build_artifacts_size: 4.exabytes - 1, + build_artifacts_size: 4.exabytes - 1 ) statistics.reload @@ -149,7 +149,7 @@ describe ProjectStatistics, models: true do it "sums all storage counters" do statistics.update!( repository_size: 2, - lfs_objects_size: 3, + lfs_objects_size: 3 ) statistics.reload diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index fb2d5f60009..362565506e5 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -327,69 +327,114 @@ describe ProjectTeam, models: true do end end - shared_examples_for "#max_member_access_for_users" do |enable_request_store| - describe "#max_member_access_for_users" do + shared_examples 'max member access for users' do + let(:project) { create(:project) } + let(:group) { create(:group) } + let(:second_group) { create(:group) } + + let(:master) { create(:user) } + let(:reporter) { create(:user) } + let(:guest) { create(:user) } + + let(:promoted_guest) { create(:user) } + + let(:group_developer) { create(:user) } + let(:second_developer) { create(:user) } + + let(:user_without_access) { create(:user) } + let(:second_user_without_access) { create(:user) } + + let(:users) do + [master, reporter, promoted_guest, guest, group_developer, second_developer, user_without_access].map(&:id) + end + + let(:expected) do + { + master.id => Gitlab::Access::MASTER, + reporter.id => Gitlab::Access::REPORTER, + promoted_guest.id => Gitlab::Access::DEVELOPER, + guest.id => Gitlab::Access::GUEST, + group_developer.id => Gitlab::Access::DEVELOPER, + second_developer.id => Gitlab::Access::MASTER, + user_without_access.id => Gitlab::Access::NO_ACCESS + } + end + + before do + project.add_master(master) + project.add_reporter(reporter) + project.add_guest(promoted_guest) + project.add_guest(guest) + + project.project_group_links.create( + group: group, + group_access: Gitlab::Access::DEVELOPER + ) + + group.add_master(promoted_guest) + group.add_developer(group_developer) + group.add_developer(second_developer) + + project.project_group_links.create( + group: second_group, + group_access: Gitlab::Access::MASTER + ) + + second_group.add_master(second_developer) + end + + it 'returns correct roles for different users' do + expect(project.team.max_member_access_for_user_ids(users)).to eq(expected) + end + end + + describe '#max_member_access_for_user_ids' do + context 'with RequestStore enabled' do before do - RequestStore.begin! if enable_request_store + RequestStore.begin! end after do - if enable_request_store - RequestStore.end! - RequestStore.clear! - end + RequestStore.end! + RequestStore.clear! end - it 'returns correct roles for different users' do - master = create(:user) - reporter = create(:user) - promoted_guest = create(:user) - guest = create(:user) - project = create(:empty_project) + include_examples 'max member access for users' - project.add_master(master) - project.add_reporter(reporter) - project.add_guest(promoted_guest) - project.add_guest(guest) + def access_levels(users) + project.team.max_member_access_for_user_ids(users) + end + + it 'does not perform extra queries when asked for users who have already been found' do + access_levels(users) + + expect { access_levels(users) }.not_to exceed_query_limit(0) - group = create(:group) - group_developer = create(:user) - second_developer = create(:user) - project.project_group_links.create( - group: group, - group_access: Gitlab::Access::DEVELOPER) - - group.add_master(promoted_guest) - group.add_developer(group_developer) - group.add_developer(second_developer) - - second_group = create(:group) - project.project_group_links.create( - group: second_group, - group_access: Gitlab::Access::MASTER) - second_group.add_master(second_developer) - - users = [master, reporter, promoted_guest, guest, group_developer, second_developer].map(&:id) - - expected = { - master.id => Gitlab::Access::MASTER, - reporter.id => Gitlab::Access::REPORTER, - promoted_guest.id => Gitlab::Access::DEVELOPER, - guest.id => Gitlab::Access::GUEST, - group_developer.id => Gitlab::Access::DEVELOPER, - second_developer.id => Gitlab::Access::MASTER - } - - expect(project.team.max_member_access_for_user_ids(users)).to eq(expected) + expect(access_levels(users)).to eq(expected) end - end - end - describe '#max_member_access_for_users with RequestStore' do - it_behaves_like "#max_member_access_for_users", true - end + it 'only requests the extra users when uncached users are passed' do + new_user = create(:user) + second_new_user = create(:user) + all_users = users + [new_user.id, second_new_user.id] + + expected_all = expected.merge(new_user.id => Gitlab::Access::NO_ACCESS, + second_new_user.id => Gitlab::Access::NO_ACCESS) + + access_levels(users) - describe '#max_member_access_for_users without RequestStore' do - it_behaves_like "#max_member_access_for_users", false + queries = ActiveRecord::QueryRecorder.new { access_levels(all_users) } + + expect(queries.count).to eq(1) + expect(queries.log_message).to match(/\W#{new_user.id}\W/) + expect(queries.log_message).to match(/\W#{second_new_user.id}\W/) + expect(queries.log_message).not_to match(/\W#{promoted_guest.id}\W/) + expect(access_levels(all_users)).to eq(expected_all) + end + end + + context 'with RequestStore disabled' do + include_examples 'max member access for users' + end end end diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 969e9f7a130..224067f58dd 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -37,21 +37,11 @@ describe ProjectWiki, models: true do describe "#http_url_to_repo" do let(:project) { create :empty_project } - context 'when no user is given' do - it 'returns the url to the repo without a username' do - expected_url = "#{Gitlab.config.gitlab.url}/#{subject.path_with_namespace}.git" + it 'returns the full http url to the repo' do + expected_url = "#{Gitlab.config.gitlab.url}/#{subject.path_with_namespace}.git" - expect(project_wiki.http_url_to_repo).to eq(expected_url) - expect(project_wiki.http_url_to_repo).not_to include('@') - end - end - - context 'when user is given' do - it 'returns the url to the repo with the username' do - user = build_stubbed(:user) - - expect(project_wiki.http_url_to_repo(user)).to start_with("http://#{user.username}@") - end + expect(project_wiki.http_url_to_repo).to eq(expected_url) + expect(project_wiki.http_url_to_repo).not_to include('@') end end diff --git a/spec/models/protected_branch/merge_access_level_spec.rb b/spec/models/protected_branch/merge_access_level_spec.rb new file mode 100644 index 00000000000..1e7242e9fa8 --- /dev/null +++ b/spec/models/protected_branch/merge_access_level_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe ProtectedBranch::MergeAccessLevel, :models do + it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MASTER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) } +end diff --git a/spec/models/protected_branch/push_access_level_spec.rb b/spec/models/protected_branch/push_access_level_spec.rb new file mode 100644 index 00000000000..de68351198c --- /dev/null +++ b/spec/models/protected_branch/push_access_level_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe ProtectedBranch::PushAccessLevel, :models do + it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MASTER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) } +end diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb index 179a443c43d..ca347cf92c9 100644 --- a/spec/models/protected_branch_spec.rb +++ b/spec/models/protected_branch_spec.rb @@ -7,9 +7,6 @@ describe ProtectedBranch, models: true do it { is_expected.to belong_to(:project) } end - describe "Mass assignment" do - end - describe 'Validation' do it { is_expected.to validate_presence_of(:project) } it { is_expected.to validate_presence_of(:name) } diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 3209589ca52..4c412aa8de6 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Repository, models: true do include RepoHelpers - TestBlob = Struct.new(:name) + TestBlob = Struct.new(:path) let(:project) { create(:project, :repository) } let(:repository) { project.repository } @@ -554,31 +554,31 @@ describe Repository, models: true do it 'accepts changelog' do expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('changelog')]) - expect(repository.changelog.name).to eq('changelog') + expect(repository.changelog.path).to eq('changelog') end it 'accepts news instead of changelog' do expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('news')]) - expect(repository.changelog.name).to eq('news') + expect(repository.changelog.path).to eq('news') end it 'accepts history instead of changelog' do expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('history')]) - expect(repository.changelog.name).to eq('history') + expect(repository.changelog.path).to eq('history') end it 'accepts changes instead of changelog' do expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('changes')]) - expect(repository.changelog.name).to eq('changes') + expect(repository.changelog.path).to eq('changes') end it 'is case-insensitive' do expect(repository.tree).to receive(:blobs).and_return([TestBlob.new('CHANGELOG')]) - expect(repository.changelog.name).to eq('CHANGELOG') + expect(repository.changelog.path).to eq('CHANGELOG') end end @@ -613,7 +613,7 @@ describe Repository, models: true do repository.create_file(user, 'LICENSE', 'Copyright!', message: 'Add LICENSE', branch_name: 'master') - expect(repository.license_blob.name).to eq('LICENSE') + expect(repository.license_blob.path).to eq('LICENSE') end %w[LICENSE LICENCE LiCensE LICENSE.md LICENSE.foo COPYING COPYING.md].each do |filename| @@ -643,7 +643,7 @@ describe Repository, models: true do expect(repository.license_key).to be_nil end - it 'detects license file with no recognizable open-source license content' do + it 'returns nil when the content is not recognizable' do repository.create_file(user, 'LICENSE', 'Copyright!', message: 'Add LICENSE', branch_name: 'master') @@ -659,12 +659,45 @@ describe Repository, models: true do end end + describe '#license' do + before do + repository.delete_file(user, 'LICENSE', + message: 'Remove LICENSE', branch_name: 'master') + end + + it 'returns nil when no license is detected' do + expect(repository.license).to be_nil + end + + it 'returns nil when the repository does not exist' do + expect(repository).to receive(:exists?).and_return(false) + + expect(repository.license).to be_nil + end + + it 'returns nil when the content is not recognizable' do + repository.create_file(user, 'LICENSE', 'Copyright!', + message: 'Add LICENSE', branch_name: 'master') + + expect(repository.license).to be_nil + end + + it 'returns the license' do + license = Licensee::License.new('mit') + repository.create_file(user, 'LICENSE', + license.content, + message: 'Add LICENSE', branch_name: 'master') + + expect(repository.license).to eq(license) + end + end + describe "#gitlab_ci_yml", caching: true do it 'returns valid file' do files = [TestBlob.new('file'), TestBlob.new('.gitlab-ci.yml'), TestBlob.new('copying')] expect(repository.tree).to receive(:blobs).and_return(files) - expect(repository.gitlab_ci_yml.name).to eq('.gitlab-ci.yml') + expect(repository.gitlab_ci_yml.path).to eq('.gitlab-ci.yml') end it 'returns nil if not exists' do @@ -1615,15 +1648,25 @@ describe Repository, models: true do describe '#readme', caching: true do context 'with a non-existing repository' do it 'returns nil' do - expect(repository).to receive(:tree).with(:head).and_return(nil) + allow(repository).to receive(:tree).with(:head).and_return(nil) expect(repository.readme).to be_nil end end context 'with an existing repository' do - it 'returns the README' do - expect(repository.readme).to be_an_instance_of(Gitlab::Git::Blob) + context 'when no README exists' do + it 'returns nil' do + allow_any_instance_of(Tree).to receive(:readme).and_return(nil) + + expect(repository.readme).to be_nil + end + end + + context 'when a README exists' do + it 'returns the README' do + expect(repository.readme).to be_an_instance_of(ReadmeBlob) + end end end end @@ -1814,11 +1857,12 @@ describe Repository, models: true do describe '#refresh_method_caches' do it 'refreshes the caches of the given types' do expect(repository).to receive(:expire_method_caches). - with(%i(rendered_readme license_blob license_key)) + with(%i(rendered_readme license_blob license_key license)) expect(repository).to receive(:rendered_readme) expect(repository).to receive(:license_blob) expect(repository).to receive(:license_key) + expect(repository).to receive(:license) repository.refresh_method_caches(%i(readme license)) end @@ -1861,6 +1905,7 @@ describe Repository, models: true do end describe '#is_ancestor?' do +<<<<<<< HEAD context 'Gitaly is_ancestor feature enabled' do let(:commit) { repository.commit } let(:ancestor) { commit.parents.first } @@ -1874,6 +1919,45 @@ describe Repository, models: true do expect_any_instance_of(Gitlab::GitalyClient::Commit).to receive(:is_ancestor).with(ancestor.id, commit.id) repository.is_ancestor?(ancestor.id, commit.id) +======= + let(:commit) { repository.commit } + let(:ancestor) { commit.parents.first } + + context 'with Gitaly enabled' do + it 'it is an ancestor' do + expect(repository.is_ancestor?(ancestor.id, commit.id)).to eq(true) + end + + it 'it is not an ancestor' do + expect(repository.is_ancestor?(commit.id, ancestor.id)).to eq(false) + end + + it 'returns false on nil-values' do + expect(repository.is_ancestor?(nil, commit.id)).to eq(false) + expect(repository.is_ancestor?(ancestor.id, nil)).to eq(false) + expect(repository.is_ancestor?(nil, nil)).to eq(false) + end + end + + context 'with Gitaly disabled' do + before do + allow(Gitlab::GitalyClient).to receive(:enabled?).and_return(false) + allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:is_ancestor).and_return(false) + end + + it 'it is an ancestor' do + expect(repository.is_ancestor?(ancestor.id, commit.id)).to eq(true) + end + + it 'it is not an ancestor' do + expect(repository.is_ancestor?(commit.id, ancestor.id)).to eq(false) + end + + it 'returns false on nil-values' do + expect(repository.is_ancestor?(nil, commit.id)).to eq(false) + expect(repository.is_ancestor?(ancestor.id, nil)).to eq(false) + expect(repository.is_ancestor?(nil, nil)).to eq(false) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index eac9a6d8e64..4a25e79b6c4 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -13,6 +13,10 @@ describe User, models: true do it { is_expected.to include_module(TokenAuthenticatable) } end + describe 'delegations' do + it { is_expected.to delegate_method(:path).to(:namespace).with_prefix } + end + describe 'associations' do it { is_expected.to have_one(:namespace) } it { is_expected.to have_many(:snippets).dependent(:destroy) } @@ -22,7 +26,7 @@ describe User, models: true do it { is_expected.to have_many(:deploy_keys).dependent(:destroy) } it { is_expected.to have_many(:events).dependent(:destroy) } it { is_expected.to have_many(:recent_events).class_name('Event') } - it { is_expected.to have_many(:issues).dependent(:restrict_with_exception) } + it { is_expected.to have_many(:issues).dependent(:destroy) } it { is_expected.to have_many(:notes).dependent(:destroy) } it { is_expected.to have_many(:merge_requests).dependent(:destroy) } it { is_expected.to have_many(:identities).dependent(:destroy) } @@ -344,6 +348,35 @@ describe User, models: true do end end + describe '#update_tracked_fields!', :redis do + let(:request) { OpenStruct.new(remote_ip: "127.0.0.1") } + let(:user) { create(:user) } + + it 'writes trackable attributes' do + expect do + user.update_tracked_fields!(request) + end.to change { user.reload.current_sign_in_at } + end + + it 'does not write trackable attributes when called a second time within the hour' do + user.update_tracked_fields!(request) + + expect do + user.update_tracked_fields!(request) + end.not_to change { user.reload.current_sign_in_at } + end + + it 'writes trackable attributes for a different user' do + user2 = create(:user) + + user.update_tracked_fields!(request) + + expect do + user2.update_tracked_fields!(request) + end.to change { user2.reload.current_sign_in_at } + end + end + shared_context 'user keys' do let(:user) { create(:user) } let!(:key) { create(:key, user: user) } @@ -411,6 +444,22 @@ describe User, models: true do end end + describe 'ensure incoming email token' do + it 'has incoming email token' do + user = create(:user) + expect(user.incoming_email_token).not_to be_blank + end + end + + describe 'rss token' do + it 'ensures an rss token on read' do + user = create(:user, rss_token: nil) + rss_token = user.rss_token + expect(rss_token).not_to be_blank + expect(user.reload.rss_token).to eq rss_token + end + end + describe '#recently_sent_password_reset?' do it 'is false when reset_password_sent_at is nil' do user = build_stubbed(:user, reset_password_sent_at: nil) @@ -637,7 +686,7 @@ describe User, models: true do protocol_and_expectation = { 'http' => false, 'ssh' => true, - '' => true, + '' => true } protocol_and_expectation.each do |protocol, expected| @@ -935,12 +984,23 @@ describe User, models: true do describe '#avatar_url' do let(:user) { create(:user, :with_avatar) } - subject { user.avatar_url } context 'when avatar file is uploaded' do +<<<<<<< HEAD let(:avatar_path) { "/uploads/system/user/avatar/#{user.id}/dk.png" } +======= + let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" } + let(:avatar_path) { "/uploads/user/avatar/#{user.id}/dk.png" } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 + + it 'shows correct avatar url' do + expect(user.avatar_url).to eq(avatar_path) + expect(user.avatar_url(only_path: false)).to eq([gitlab_host, avatar_path].join) - it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host) + + expect(user.avatar_url).to eq([gitlab_host, avatar_path].join) + end end end @@ -1444,25 +1504,6 @@ describe User, models: true do end end - describe '#viewable_starred_projects' do - let(:user) { create(:user) } - let(:public_project) { create(:empty_project, :public) } - let(:private_project) { create(:empty_project, :private) } - let(:private_viewable_project) { create(:empty_project, :private) } - - before do - private_viewable_project.team << [user, Gitlab::Access::MASTER] - - [public_project, private_project, private_viewable_project].each do |project| - user.toggle_star(project) - end - end - - it 'returns only starred projects the user can view' do - expect(user.viewable_starred_projects).not_to include(private_project) - end - end - describe '#projects_with_reporter_access_limited_to' do let(:project1) { create(:empty_project) } let(:project2) { create(:empty_project) } @@ -1574,10 +1615,17 @@ describe User, models: true do ] end end +<<<<<<< HEAD context 'user is member of the first child (internal node), branch 2', :nested_groups do before { nested_group_2.add_owner(user) } +======= + + context 'user is member of the first child (internal node), branch 2', :nested_groups do + before { nested_group_2.add_owner(user) } + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 it 'returns the groups in the hierarchy' do is_expected.to match_array [ group, @@ -1792,4 +1840,32 @@ describe User, models: true do expect(user.preferred_language).to eq('en') end end + + context '#invalidate_issue_cache_counts' do + let(:user) { build_stubbed(:user) } + + it 'invalidates cache for issue counter' do + cache_mock = double + + expect(cache_mock).to receive(:delete).with(['users', user.id, 'assigned_open_issues_count']) + + allow(Rails).to receive(:cache).and_return(cache_mock) + + user.invalidate_issue_cache_counts + end + end + + context '#invalidate_merge_request_cache_counts' do + let(:user) { build_stubbed(:user) } + + it 'invalidates cache for Merge Request counter' do + cache_mock = double + + expect(cache_mock).to receive(:delete).with(['users', user.id, 'assigned_open_merge_requests_count']) + + allow(Rails).to receive(:cache).and_return(cache_mock) + + user.invalidate_merge_request_cache_counts + end + end end diff --git a/spec/policies/deploy_key_policy_spec.rb b/spec/policies/deploy_key_policy_spec.rb new file mode 100644 index 00000000000..28e10f0bfe2 --- /dev/null +++ b/spec/policies/deploy_key_policy_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe DeployKeyPolicy, models: true do + subject { described_class.abilities(current_user, deploy_key).to_set } + + describe 'updating a deploy_key' do + context 'when a regular user' do + let(:current_user) { create(:user) } + + context 'tries to update private deploy key attached to project' do + let(:deploy_key) { create(:deploy_key, public: false) } + let(:project) { create(:project_empty_repo) } + + before do + project.add_master(current_user) + project.deploy_keys << deploy_key + end + + it { is_expected.to include(:update_deploy_key) } + end + + context 'tries to update private deploy key attached to other project' do + let(:deploy_key) { create(:deploy_key, public: false) } + let(:other_project) { create(:project_empty_repo) } + + before do + other_project.deploy_keys << deploy_key + end + + it { is_expected.not_to include(:update_deploy_key) } + end + + context 'tries to update public deploy key' do + let(:deploy_key) { create(:another_deploy_key, public: true) } + + it { is_expected.not_to include(:update_deploy_key) } + end + end + + context 'when an admin user' do + let(:current_user) { create(:user, :admin) } + + context ' tries to update private deploy key' do + let(:deploy_key) { create(:deploy_key, public: false) } + + it { is_expected.to include(:update_deploy_key) } + end + + context 'when an admin user tries to update public deploy key' do + let(:deploy_key) { create(:another_deploy_key, public: true) } + + it { is_expected.to include(:update_deploy_key) } + end + end + end +end diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 4c37a553227..c09c7a8b61a 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -9,11 +9,12 @@ describe GroupPolicy, models: true do let(:admin) { create(:admin) } let(:group) { create(:group) } + let(:reporter_permissions) { [:admin_label] } + let(:master_permissions) do [ :create_projects, - :admin_milestones, - :admin_label + :admin_milestones ] end @@ -42,6 +43,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.not_to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -52,6 +54,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.not_to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -62,6 +65,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -72,6 +76,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -82,6 +87,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -92,6 +98,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.to include(*master_permissions) is_expected.to include(*owner_permissions) end @@ -102,14 +109,31 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.to include(*master_permissions) is_expected.to include(*owner_permissions) end end +<<<<<<< HEAD describe 'private nested group inherit permissions', :nested_groups do +======= + describe 'private nested group use the highest access level from the group and inherited permissions', :nested_groups do +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 let(:nested_group) { create(:group, :private, parent: group) } + before do + nested_group.add_guest(guest) + nested_group.add_guest(reporter) + nested_group.add_guest(developer) + nested_group.add_guest(master) + + group.owners.destroy_all + + group.add_guest(owner) + nested_group.add_owner(owner) + end + subject { described_class.abilities(current_user, nested_group).to_set } context 'with no user' do @@ -117,6 +141,7 @@ describe GroupPolicy, models: true do it do is_expected.not_to include(:read_group) + is_expected.not_to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -127,6 +152,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.not_to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -137,6 +163,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -147,6 +174,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -157,6 +185,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -167,6 +196,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.to include(*master_permissions) is_expected.to include(*owner_permissions) end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 064847ee3dc..0d3af1f4499 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -43,7 +43,7 @@ describe ProjectPolicy, models: true do let(:master_permissions) do %i[ - push_code_to_protected_branches update_project_snippet update_environment + delete_protected_branch 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 diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb index ddbed5f781e..f07f76c5e6b 100644 --- a/spec/policies/project_snippet_policy_spec.rb +++ b/spec/policies/project_snippet_policy_spec.rb @@ -3,7 +3,11 @@ require 'spec_helper' describe ProjectSnippetPolicy, models: true do let(:regular_user) { create(:user) } let(:external_user) { create(:user, :external) } +<<<<<<< HEAD let(:project) { create(:empty_project, :public) } +======= + let(:project) { create(:empty_project) } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 let(:author_permissions) do [ @@ -107,7 +111,11 @@ describe ProjectSnippetPolicy, models: true do end context 'snippet author' do +<<<<<<< HEAD let(:snippet) { create(:project_snippet, :private, author: regular_user, project: project) } +======= + let(:snippet) { create(:project_snippet, :private, author: regular_user) } +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 subject { described_class.abilities(regular_user, snippet).to_set } diff --git a/spec/presenters/conversational_development_index/metric_presenter_spec.rb b/spec/presenters/conversational_development_index/metric_presenter_spec.rb new file mode 100644 index 00000000000..1e015c71f5b --- /dev/null +++ b/spec/presenters/conversational_development_index/metric_presenter_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe ConversationalDevelopmentIndex::MetricPresenter do + subject { described_class.new(metric) } + let(:metric) { build(:conversational_development_index_metric) } + + describe '#cards' do + it 'includes instance score, leader score and percentage score' do + issues_card = subject.cards.first + + expect(issues_card.instance_score).to eq 1.234 + expect(issues_card.leader_score).to eq 9.256 + expect(issues_card.percentage_score).to be_within(0.1).of(13.3) + end + end + + describe '#idea_to_production_steps' do + it 'returns percentage score when it depends on a single feature' do + code_step = subject.idea_to_production_steps.fourth + + expect(code_step.percentage_score).to be_within(0.1).of(50.0) + end + + it 'returns percentage score when it depends on two features' do + issue_step = subject.idea_to_production_steps.second + + expect(issue_step.percentage_score).to be_within(0.1).of(53.0) + end + end + + describe '#average_percentage_score' do + it 'calculates an average value across all the features' do + expect(subject.average_percentage_score).to be_within(0.1).of(55.8) + end + end +end diff --git a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb index 6443f86b6a1..5c39e1b5f96 100644 --- a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb +++ b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb @@ -51,10 +51,6 @@ describe Projects::Settings::DeployKeysPresenter do expect(presenter.available_project_keys).not_to be_empty end - it 'returns false if any available_project_keys are enabled' do - expect(presenter.any_available_project_keys_enabled?).to eq(true) - end - it 'returns the available_project_keys size' do expect(presenter.available_project_keys_size).to eq(1) end diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index 7eaa89837c8..c64499fc8c0 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -406,19 +406,6 @@ describe API::Branches do delete api("/projects/#{project.id}/repository/branches/foobar", user) expect(response).to have_http_status(404) end - - 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 "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') - end end describe "DELETE /projects/:id/repository/merged_branches" do diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb index 1c163cee152..6b637a03b6f 100644 --- a/spec/requests/api/commit_statuses_spec.rb +++ b/spec/requests/api/commit_statuses_spec.rb @@ -16,8 +16,8 @@ describe API::CommitStatuses do let(:get_url) { "/projects/#{project.id}/repository/commits/#{sha}/statuses" } context 'ci commit exists' do - let!(:master) { project.pipelines.create(sha: commit.id, ref: 'master') } - let!(:develop) { project.pipelines.create(sha: commit.id, ref: 'develop') } + let!(:master) { project.pipelines.create(source: :push, sha: commit.id, ref: 'master') } + let!(:develop) { project.pipelines.create(source: :push, sha: commit.id, ref: 'develop') } context "reporter user" do let(:statuses_id) { json_response.map { |status| status['id'] } } diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index b84361d3abd..b0c265b6453 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -485,7 +485,7 @@ describe API::Commits do end it "returns status for CI" do - pipeline = project.ensure_pipeline('master', project.repository.commit.sha) + pipeline = project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) pipeline.update(status: 'success') get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) @@ -495,7 +495,7 @@ describe API::Commits do end it "returns status for CI when pipeline is created" do - project.ensure_pipeline('master', project.repository.commit.sha) + project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb index 843e9862b0c..4d9cd5f3a27 100644 --- a/spec/requests/api/deploy_keys_spec.rb +++ b/spec/requests/api/deploy_keys_spec.rb @@ -13,7 +13,7 @@ describe API::DeployKeys do describe 'GET /deploy_keys' do context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do get api('/deploy_keys') expect(response.status).to eq(401) @@ -21,7 +21,7 @@ describe API::DeployKeys do end context 'when authenticated as non-admin user' do - it 'should return a 403 error' do + it 'returns a 403 error' do get api('/deploy_keys', user) expect(response.status).to eq(403) @@ -29,7 +29,7 @@ describe API::DeployKeys do end context 'when authenticated as admin' do - it 'should return all deploy keys' do + it 'returns all deploy keys' do get api('/deploy_keys', admin) expect(response.status).to eq(200) @@ -43,7 +43,7 @@ describe API::DeployKeys do describe 'GET /projects/:id/deploy_keys' do before { deploy_key } - it 'should return array of ssh keys' do + it 'returns array of ssh keys' do get api("/projects/#{project.id}/deploy_keys", admin) expect(response).to have_http_status(200) @@ -54,14 +54,14 @@ describe API::DeployKeys do end describe 'GET /projects/:id/deploy_keys/:key_id' do - it 'should return a single key' do + it 'returns 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 + it 'returns 404 Not Found with invalid ID' do get api("/projects/#{project.id}/deploy_keys/404", admin) expect(response).to have_http_status(404) @@ -69,26 +69,26 @@ describe API::DeployKeys do end describe 'POST /projects/:id/deploy_keys' do - it 'should not create an invalid ssh key' do + it 'does 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['error']).to eq('key is missing') end - it 'should not create a key without title' do + it 'does 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['error']).to eq('title is missing') end - it 'should create new ssh key' do + it 'creates 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.to change { project.deploy_keys.count }.by(1) end it 'returns an existing ssh key when attempting to add a duplicate' do @@ -117,10 +117,53 @@ describe API::DeployKeys do end end + describe 'PUT /projects/:id/deploy_keys/:key_id' do + let(:private_deploy_key) { create(:another_deploy_key, public: false) } + let(:project_private_deploy_key) do + create(:deploy_keys_project, project: project, deploy_key: private_deploy_key) + end + + it 'updates a public deploy key as admin' do + expect do + put api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin), { title: 'new title' } + end.not_to change(deploy_key, :title) + + expect(response).to have_http_status(200) + end + + it 'does not update a public deploy key as non admin' do + expect do + put api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", user), { title: 'new title' } + end.not_to change(deploy_key, :title) + + expect(response).to have_http_status(404) + end + + it 'does not update a private key with invalid title' do + project_private_deploy_key + + expect do + put api("/projects/#{project.id}/deploy_keys/#{private_deploy_key.id}", admin), { title: '' } + end.not_to change(deploy_key, :title) + + expect(response).to have_http_status(400) + end + + it 'updates a private ssh key with correct attributes' do + project_private_deploy_key + + put api("/projects/#{project.id}/deploy_keys/#{private_deploy_key.id}", admin), { title: 'new title', can_push: true } + + expect(json_response['id']).to eq(private_deploy_key.id) + expect(json_response['title']).to eq('new title') + expect(json_response['can_push']).to eq(true) + end + end + describe 'DELETE /projects/:id/deploy_keys/:key_id' do before { deploy_key } - it 'should delete existing key' do + it 'deletes existing key' do expect do delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin) @@ -128,7 +171,7 @@ describe API::DeployKeys do end.to change{ project.deploy_keys.count }.by(-1) end - it 'should return 404 Not Found with invalid ID' do + it 'returns 404 Not Found with invalid ID' do delete api("/projects/#{project.id}/deploy_keys/404", admin) expect(response).to have_http_status(404) @@ -150,7 +193,7 @@ describe API::DeployKeys do end context 'when authenticated as non-admin user' do - it 'should return a 404 error' do + it 'returns a 404 error' do post api("/projects/#{project2.id}/deploy_keys/#{deploy_key.id}/enable", user) expect(response).to have_http_status(404) diff --git a/spec/requests/api/events_spec.rb b/spec/requests/api/events_spec.rb new file mode 100644 index 00000000000..a19870a95e8 --- /dev/null +++ b/spec/requests/api/events_spec.rb @@ -0,0 +1,142 @@ +require 'spec_helper' + +describe API::Events, api: true do + include ApiHelpers + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:other_user) { create(:user, username: 'otheruser') } + let(:private_project) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) } + let(:closed_issue) { create(:closed_issue, project: private_project, author: user) } + let!(:closed_issue_event) { create(:event, project: private_project, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) } + + describe 'GET /events' do + context 'when unauthenticated' do + it 'returns authentication error' do + get api('/events') + + expect(response).to have_http_status(401) + end + end + + context 'when authenticated' do + it 'returns users events' do + get api('/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31', user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + end + end + + describe 'GET /users/:id/events' do + context "as a user that cannot see the event's project" do + it 'returns no events' do + 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 that can see the event's project" do + it 'accepts a username' do + get api("/users/#{user.username}/events", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + + it 'returns the events' do + get api("/users/#{user.id}/events", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + + context 'when there are multiple events from different projects' do + let(:second_note) { create(:note_on_issue, project: create(:empty_project)) } + + before do + second_note.project.add_user(user, :developer) + + [second_note].each do |note| + EventCreateService.new.leave_note(note, user) + end + end + + it 'returns events in the correct order (from newest to oldest)' do + get api("/users/#{user.id}/events", user) + + comment_events = json_response.select { |e| e['action_name'] == 'commented on' } + close_events = json_response.select { |e| e['action_name'] == 'closed' } + + expect(comment_events[0]['target_id']).to eq(second_note.id) + expect(close_events[0]['target_id']).to eq(closed_issue.id) + end + + it 'accepts filter parameters' do + get api("/users/#{user.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user) + + expect(json_response.size).to eq(1) + expect(json_response[0]['target_id']).to eq(closed_issue.id) + 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 + + describe 'GET /projects/:id/events' do + context 'when unauthenticated ' do + it 'returns 404 for private project' do + get api("/projects/#{private_project.id}/events") + + expect(response).to have_http_status(404) + end + + it 'returns 200 status for a public project' do + public_project = create(:empty_project, :public) + + get api("/projects/#{public_project.id}/events") + + expect(response).to have_http_status(200) + end + end + + context 'when not permitted to read' do + it 'returns 404' do + get api("/projects/#{private_project.id}/events", non_member) + + expect(response).to have_http_status(404) + end + end + + context 'when authenticated' do + it 'returns project events' do + get api("/projects/#{private_project.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + + it 'returns 404 if project does not exist' do + get api("/projects/1234/events", user) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb new file mode 100644 index 00000000000..f169e6661d1 --- /dev/null +++ b/spec/requests/api/features_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe API::Features do + let(:user) { create(:user) } + let(:admin) { create(:admin) } + + describe 'GET /features' do + let(:expected_features) do + [ + { + 'name' => 'feature_1', + 'state' => 'on', + 'gates' => [{ 'key' => 'boolean', 'value' => true }] + }, + { + 'name' => 'feature_2', + 'state' => 'off', + 'gates' => [{ 'key' => 'boolean', 'value' => false }] + } + ] + end + + before do + Feature.get('feature_1').enable + Feature.get('feature_2').disable + end + + it 'returns a 401 for anonymous users' do + get api('/features') + + expect(response).to have_http_status(401) + end + + it 'returns a 403 for users' do + get api('/features', user) + + expect(response).to have_http_status(403) + end + + it 'returns the feature list for admins' do + get api('/features', admin) + + expect(response).to have_http_status(200) + expect(json_response).to match_array(expected_features) + end + end + + describe 'POST /feature' do + let(:feature_name) { 'my_feature' } + it 'returns a 401 for anonymous users' do + post api("/features/#{feature_name}") + + expect(response).to have_http_status(401) + end + + it 'returns a 403 for users' do + post api("/features/#{feature_name}", user) + + expect(response).to have_http_status(403) + end + + it 'creates an enabled feature if passed true' do + post api("/features/#{feature_name}", admin), value: 'true' + + expect(response).to have_http_status(201) + expect(Feature.get(feature_name)).to be_enabled + end + + it 'creates a feature with the given percentage if passed an integer' do + post api("/features/#{feature_name}", admin), value: '50' + + expect(response).to have_http_status(201) + expect(Feature.get(feature_name).percentage_of_time_value).to be(50) + end + + context 'when the feature exists' do + let(:feature) { Feature.get(feature_name) } + + before do + feature.disable # This also persists the feature on the DB + end + + it 'enables the feature if passed true' do + post api("/features/#{feature_name}", admin), value: 'true' + + expect(response).to have_http_status(201) + expect(feature).to be_enabled + end + + context 'with a pre-existing percentage value' do + before do + feature.enable_percentage_of_time(50) + end + + it 'updates the percentage of time if passed an integer' do + post api("/features/#{feature_name}", admin), value: '30' + + expect(response).to have_http_status(201) + expect(Feature.get(feature_name).percentage_of_time_value).to be(30) + end + end + end + end +end diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index fa28047d49c..d325c6eff9d 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -258,6 +258,25 @@ describe API::Files do expect(last_commit.author_name).to eq(user.name) end + it "returns a 400 bad request if update existing file with stale last commit id" do + params_with_stale_id = valid_params.merge(last_commit_id: 'stale') + + put api(route(file_path), user), params_with_stale_id + + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('You are attempting to update a file that has changed since you started editing it.') + end + + it "updates existing file in project repo with accepts correct last commit id" do + last_commit = Gitlab::Git::Commit + .last_for_path(project.repository, 'master', URI.unescape(file_path)) + params_with_correct_id = valid_params.merge(last_commit_id: last_commit.id) + + put api(route(file_path), user), params_with_correct_id + + expect(response).to have_http_status(200) + end + it "returns a 400 bad request if no params given" do put api(route(file_path), user) @@ -329,7 +348,7 @@ describe API::Files do end let(:get_params) do { - ref: 'master', + ref: 'master' } end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 9fb303be1b5..bb53796cbd7 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -73,7 +73,7 @@ describe API::Groups do storage_size: 702, repository_size: 123, lfs_objects_size: 234, - build_artifacts_size: 345, + build_artifacts_size: 345 }.stringify_keys exposed_attributes = attributes.dup exposed_attributes['job_artifacts_size'] = exposed_attributes.delete('build_artifacts_size') @@ -178,7 +178,7 @@ describe API::Groups do expect(json_response['path']).to eq(group1.path) expect(json_response['description']).to eq(group1.description) expect(json_response['visibility']).to eq(Gitlab::VisibilityLevel.string_level(group1.visibility_level)) - expect(json_response['avatar_url']).to eq(group1.avatar_url) + expect(json_response['avatar_url']).to eq(group1.avatar_url(only_path: false)) expect(json_response['web_url']).to eq(group1.web_url) expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled) expect(json_response['full_name']).to eq(group1.full_name) diff --git a/spec/requests/api/pipeline_schedules_spec.rb b/spec/requests/api/pipeline_schedules_spec.rb new file mode 100644 index 00000000000..85d11deb26f --- /dev/null +++ b/spec/requests/api/pipeline_schedules_spec.rb @@ -0,0 +1,297 @@ +require 'spec_helper' + +describe API::PipelineSchedules do + set(:developer) { create(:user) } + set(:user) { create(:user) } + set(:project) { create(:project) } + + before do + project.add_developer(developer) + end + + describe 'GET /projects/:id/pipeline_schedules' do + context 'authenticated user with valid permissions' do + let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer) } + + before do + pipeline_schedule.pipelines << build(:ci_pipeline, project: project) + end + + it 'returns list of pipeline_schedules' do + get api("/projects/#{project.id}/pipeline_schedules", developer) + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(response).to match_response_schema('pipeline_schedules') + end + + it 'avoids N + 1 queries' do + control_count = ActiveRecord::QueryRecorder.new do + get api("/projects/#{project.id}/pipeline_schedules", developer) + end.count + + create_list(:ci_pipeline_schedule, 10, project: project) + .each do |pipeline_schedule| + create(:user).tap do |user| + project.add_developer(user) + pipeline_schedule.update_attributes(owner: user) + end + pipeline_schedule.pipelines << build(:ci_pipeline, project: project) + end + + expect do + get api("/projects/#{project.id}/pipeline_schedules", developer) + end.not_to exceed_query_limit(control_count) + end + + %w[active inactive].each do |target| + context "when scope is #{target}" do + before do + create(:ci_pipeline_schedule, project: project, active: active?(target)) + end + + it 'returns matched pipeline schedules' do + get api("/projects/#{project.id}/pipeline_schedules", developer), scope: target + + expect(json_response.map{ |r| r['active'] }).to all(eq(active?(target))) + end + end + + def active?(str) + (str == 'active') ? true : false + end + end + end + + context 'authenticated user with invalid permissions' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /projects/:id/pipeline_schedules/:pipeline_schedule_id' do + let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer) } + + before do + pipeline_schedule.pipelines << build(:ci_pipeline, project: project) + end + + context 'authenticated user with valid permissions' do + it 'returns pipeline_schedule details' do + get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer) + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('pipeline_schedule') + end + + it 'responds with 404 Not Found if requesting non-existing pipeline_schedule' do + get api("/projects/#{project.id}/pipeline_schedules/-5", developer) + + expect(response).to have_http_status(:not_found) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /projects/:id/pipeline_schedules' do + let(:params) { attributes_for(:ci_pipeline_schedule) } + + context 'authenticated user with valid permissions' do + context 'with required parameters' do + it 'creates pipeline_schedule' do + expect do + post api("/projects/#{project.id}/pipeline_schedules", developer), + params + end.to change { project.pipeline_schedules.count }.by(1) + + expect(response).to have_http_status(:created) + expect(response).to match_response_schema('pipeline_schedule') + expect(json_response['description']).to eq(params[:description]) + expect(json_response['ref']).to eq(params[:ref]) + expect(json_response['cron']).to eq(params[:cron]) + expect(json_response['cron_timezone']).to eq(params[:cron_timezone]) + expect(json_response['owner']['id']).to eq(developer.id) + end + end + + context 'without required parameters' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules", developer) + + expect(response).to have_http_status(:bad_request) + end + end + + context 'when cron has validation error' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules", developer), + params.merge('cron' => 'invalid-cron') + + expect(response).to have_http_status(:bad_request) + expect(json_response['message']).to have_key('cron') + end + end + end + + context 'authenticated user with invalid permissions' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules", user), params + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules"), params + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id' do + let(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + context 'authenticated user with valid permissions' do + it 'updates cron' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer), + cron: '1 2 3 4 *' + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('pipeline_schedule') + expect(json_response['cron']).to eq('1 2 3 4 *') + end + + context 'when cron has validation error' do + it 'does not update pipeline_schedule' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer), + cron: 'invalid-cron' + + expect(response).to have_http_status(:bad_request) + expect(json_response['message']).to have_key('cron') + end + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update pipeline_schedule' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not update pipeline_schedule' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/take_ownership' do + let(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + context 'authenticated user with valid permissions' do + it 'updates owner' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership", developer) + + expect(response).to have_http_status(:created) + expect(response).to match_response_schema('pipeline_schedule') + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update owner' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not update owner' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /projects/:id/pipeline_schedules/:pipeline_schedule_id' do + let(:master) { create(:user) } + + let!(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + before do + project.add_master(master) + end + + context 'authenticated user with valid permissions' do + it 'deletes pipeline_schedule' do + expect do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", master) + end.to change { project.pipeline_schedules.count }.by(-1) + + expect(response).to have_http_status(:accepted) + expect(response).to match_response_schema('pipeline_schedule') + end + + it 'responds with 404 Not Found if requesting non-existing pipeline_schedule' do + delete api("/projects/#{project.id}/pipeline_schedules/-5", master) + + expect(response).to have_http_status(:not_found) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not delete pipeline_schedule' do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer) + + expect(response).to have_http_status(:forbidden) + end + end + + context 'unauthenticated user' do + it 'does not delete pipeline_schedule' do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}") + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index f9e5316b3de..9e6957e9922 100644 --- a/spec/requests/api/pipelines_spec.rb +++ b/spec/requests/api/pipelines_spec.rb @@ -7,7 +7,7 @@ describe API::Pipelines do let!(:pipeline) do create(:ci_empty_pipeline, project: project, sha: project.commit.id, - ref: project.default_branch) + ref: project.default_branch, user: user) end before { project.team << [user, :master] } @@ -232,20 +232,26 @@ describe API::Pipelines do context 'when order_by and sort are specified' do context 'when order_by user_id' do - let!(:pipeline) { create_list(:ci_pipeline, 2, project: project, user: create(:user)) } + before do + 3.times do + create(:ci_pipeline, project: project, user: create(:user)) + end + end - it 'sorts as user_id: :asc' do - get api("/projects/#{project.id}/pipelines", user), order_by: 'user_id', sort: 'asc' + context 'when sort parameter is valid' do + it 'sorts as user_id: :desc' do + get api("/projects/#{project.id}/pipelines", user), order_by: 'user_id', sort: 'desc' - expect(response).to have_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response).not_to be_empty - pipeline.sort_by { |p| p.user.id }.tap do |sorted_pipeline| - json_response.each_with_index { |r, i| expect(r['id']).to eq(sorted_pipeline[i].id) } + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).not_to be_empty + + pipeline_ids = Ci::Pipeline.all.order(user_id: :desc).pluck(:id) + expect(json_response.map { |r| r['id'] }).to eq(pipeline_ids) end end - context 'when sort is invalid' do + context 'when sort parameter is invalid' do it 'returns bad_request' do get api("/projects/#{project.id}/pipelines", user), order_by: 'user_id', sort: 'invalid_sort' diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index aee0e17a153..0f9330b062d 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -60,7 +60,7 @@ describe API::ProjectHooks, 'ProjectHooks' 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['job_events']).to eq(hook.build_events) + expect(json_response['job_events']).to eq(hook.job_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) @@ -148,7 +148,7 @@ describe API::ProjectHooks, 'ProjectHooks' 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['job_events']).to eq(hook.build_events) + expect(json_response['job_events']).to eq(hook.job_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) diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb index 3ab1764f5c3..4d4631322b1 100644 --- a/spec/requests/api/project_snippets_spec.rb +++ b/spec/requests/api/project_snippets_spec.rb @@ -36,11 +36,34 @@ describe API::ProjectSnippets do end end + describe 'GET /projects/:project_id/snippets/:id' do + let(:user) { create(:user) } + let(:snippet) { create(:project_snippet, :public, project: project) } + + it 'returns snippet json' 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) + expect(json_response['description']).to eq(snippet.description) + expect(json_response['file_name']).to eq(snippet.file_name) + end + + it 'returns 404 for invalid snippet id' do + get api("/projects/#{project.id}/snippets/1234", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Not found') + end + end + describe 'POST /projects/:project_id/snippets/' do let(:params) do { title: 'Test Title', file_name: 'test.rb', + description: 'test description', code: 'puts "hello world"', visibility: 'public' } @@ -52,6 +75,7 @@ describe API::ProjectSnippets do expect(response).to have_http_status(201) snippet = ProjectSnippet.find(json_response['id']) expect(snippet.content).to eq(params[:code]) + expect(snippet.description).to eq(params[:description]) expect(snippet.title).to eq(params[:title]) expect(snippet.file_name).to eq(params[:file_name]) expect(snippet.visibility_level).to eq(Snippet::PUBLIC) @@ -106,12 +130,14 @@ describe API::ProjectSnippets do it 'updates snippet' do new_content = 'New content' + new_description = 'New description' - put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), code: new_content + put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), code: new_content, description: new_description expect(response).to have_http_status(200) snippet.reload expect(snippet.content).to eq(new_content) + expect(snippet.description).to eq(new_description) end it 'returns 404 for invalid snippet id' do diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index dae437ecb31..86c57204971 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -316,15 +316,15 @@ describe API::Projects do expect(project.path).to eq('foo_project') end - it 'creates new project name and path and returns 201' do - expect { post api('/projects', user), path: 'foo-Project', name: 'Foo Project' }. + it 'creates new project with name and path and returns 201' do + expect { post api('/projects', user), path: 'path-project-Foo', name: 'Foo Project' }. to change { Project.count }.by(1) expect(response).to have_http_status(201) project = Project.first expect(project.name).to eq('Foo Project') - expect(project.path).to eq('foo-Project') + expect(project.path).to eq('path-project-Foo') end it 'creates last project before reaching project limit' do @@ -390,6 +390,14 @@ describe API::Projects do expect(json_response['visibility']).to eq('private') end + it 'sets tag list to a project' do + project = attributes_for(:project, tag_list: %w[tagFirst tagSecond]) + + post api('/projects', user), project + + expect(json_response['tag_list']).to eq(%w[tagFirst tagSecond]) + end + it 'sets a project as allowing merge even if build fails' do project = attributes_for(:project, { only_allow_merge_if_pipeline_succeeds: false }) post api('/projects', user), project @@ -462,9 +470,25 @@ describe API::Projects do before { project } before { admin } - it 'creates new project without path and return 201' do - expect { post api("/projects/user/#{user.id}", admin), name: 'foo' }.to change {Project.count}.by(1) + it 'creates new project without path but with name and return 201' do + expect { post api("/projects/user/#{user.id}", admin), name: 'Foo Project' }.to change {Project.count}.by(1) expect(response).to have_http_status(201) + + project = Project.first + + expect(project.name).to eq('Foo Project') + expect(project.path).to eq('foo-project') + end + + it 'creates new project with name and path and returns 201' do + expect { post api("/projects/user/#{user.id}", admin), path: 'path-project-Foo', name: 'Foo Project' }. + to change { Project.count }.by(1) + expect(response).to have_http_status(201) + + project = Project.first + + expect(project.name).to eq('Foo Project') + expect(project.path).to eq('path-project-Foo') end it 'responds with 400 on failure and not project' do @@ -611,6 +635,8 @@ describe API::Projects do expect(json_response['shared_runners_enabled']).to be_present expect(json_response['creator_id']).to be_present expect(json_response['namespace']).to be_present + expect(json_response['import_status']).to be_present + expect(json_response).to include("import_error") expect(json_response['avatar_url']).to be_nil expect(json_response['star_count']).to be_present expect(json_response['forks_count']).to be_present @@ -660,7 +686,7 @@ describe API::Projects do 'name' => user.namespace.name, 'path' => user.namespace.path, 'kind' => user.namespace.kind, - 'full_path' => user.namespace.full_path, + 'full_path' => user.namespace.full_path }) end @@ -678,6 +704,20 @@ describe API::Projects do expect(json_response).to include 'statistics' end + it "includes import_error if user can admin project" do + get api("/projects/#{project.id}", user) + + expect(response).to have_http_status(200) + expect(json_response).to include("import_error") + end + + it "does not include import_error if user cannot admin project" do + get api("/projects/#{project.id}", user3) + + expect(response).to have_http_status(200) + expect(json_response).not_to include("import_error") + end + describe 'permissions' do context 'all projects' do before { project.team << [user, :master] } @@ -722,64 +762,6 @@ describe API::Projects do end end - describe 'GET /projects/:id/events' do - shared_examples_for 'project events response' do - it 'returns the project events' do - member = create(:user) - create(:project_member, :developer, user: member, project: project) - 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", current_user) - - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - - 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!') - - last_event = json_response.last - - 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(member.username) - expect(last_event['author']['name']).to eq(member.name) - end - end - - context 'when unauthenticated' do - it_behaves_like 'project events response' do - let(:project) { create(:empty_project, :public) } - let(:current_user) { nil } - end - end - - context 'when authenticated' do - context 'valid request' do - it_behaves_like 'project events response' do - let(:current_user) { user } - end - end - - 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 '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 - end - describe 'GET /projects/:id/users' do shared_examples_for 'project users response' do it 'returns the project users' do @@ -1440,6 +1422,8 @@ describe API::Projects do expect(json_response['owner']['id']).to eq(user2.id) expect(json_response['namespace']['id']).to eq(user2.namespace.id) expect(json_response['forked_from_project']['id']).to eq(project.id) + expect(json_response['import_status']).to eq('scheduled') + expect(json_response).to include("import_error") end it 'forks if user is admin' do @@ -1451,6 +1435,8 @@ describe API::Projects do expect(json_response['owner']['id']).to eq(admin.id) expect(json_response['namespace']['id']).to eq(admin.namespace.id) expect(json_response['forked_from_project']['id']).to eq(project.id) + expect(json_response['import_status']).to eq('scheduled') + expect(json_response).to include("import_error") end it 'fails on missing project access for the project to fork' do diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb index e429cddcf6a..8741cbd4e80 100644 --- a/spec/requests/api/snippets_spec.rb +++ b/spec/requests/api/snippets_spec.rb @@ -80,11 +80,33 @@ describe API::Snippets do end end + describe 'GET /snippets/:id' do + let(:snippet) { create(:personal_snippet, author: user) } + + it 'returns snippet json' do + get api("/snippets/#{snippet.id}", user) + + expect(response).to have_http_status(200) + + expect(json_response['title']).to eq(snippet.title) + expect(json_response['description']).to eq(snippet.description) + expect(json_response['file_name']).to eq(snippet.file_name) + end + + it 'returns 404 for invalid snippet id' do + get api("/snippets/1234", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Not found') + end + end + describe 'POST /snippets/' do let(:params) do { title: 'Test Title', file_name: 'test.rb', + description: 'test description', content: 'puts "hello world"', visibility: 'public' } @@ -97,6 +119,7 @@ describe API::Snippets do expect(response).to have_http_status(201) expect(json_response['title']).to eq(params[:title]) + expect(json_response['description']).to eq(params[:description]) expect(json_response['file_name']).to eq(params[:file_name]) end @@ -150,12 +173,14 @@ describe API::Snippets do it 'updates snippet' do new_content = 'New content' + new_description = 'New description' - put api("/snippets/#{snippet.id}", user), content: new_content + put api("/snippets/#{snippet.id}", user), content: new_content, description: new_description expect(response).to have_http_status(200) snippet.reload expect(snippet.content).to eq(new_content) + expect(snippet.description).to eq(new_description) end it 'returns 404 for invalid snippet id' do diff --git a/spec/requests/api/system_hooks_spec.rb b/spec/requests/api/system_hooks_spec.rb index c7b84173570..2eb191d6049 100644 --- a/spec/requests/api/system_hooks_spec.rb +++ b/spec/requests/api/system_hooks_spec.rb @@ -32,8 +32,9 @@ describe API::SystemHooks do expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.first['url']).to eq(hook.url) - expect(json_response.first['push_events']).to be true + expect(json_response.first['push_events']).to be false expect(json_response.first['tag_push_events']).to be false + expect(json_response.first['repository_update_events']).to be true end end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 4919ad19833..ec51b96c86b 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -287,7 +287,7 @@ describe API::Users do expect(json_response['message']['projects_limit']). to eq(['must be greater than or equal to 0']) expect(json_response['message']['username']). - to eq([Gitlab::Regex.namespace_regex_message]) + to eq([Gitlab::PathRegex.namespace_format_message]) end it "is not available for non admin users" do @@ -426,9 +426,14 @@ describe API::Users do expect(user.reload.email).not_to eq('invalid email') end - 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) + context 'when the current user is not an admin' do + it "is not available" do + expect do + put api("/users/#{user.id}", user), attributes_for(:user) + end.not_to change { user.reload.attributes } + + expect(response).to have_http_status(403) + end end it "returns 404 for non-existing user" do @@ -459,7 +464,7 @@ describe API::Users do expect(json_response['message']['projects_limit']). to eq(['must be greater than or equal to 0']) expect(json_response['message']['username']). - to eq([Gitlab::Regex.namespace_regex_message]) + to eq([Gitlab::PathRegex.namespace_format_message]) end it 'returns 400 if provider is missing for identity update' do @@ -649,7 +654,7 @@ describe API::Users do end it "returns a 404 for invalid ID" do - put api("/users/ASDF/emails", admin) + get api("/users/ASDF/emails", admin) expect(response).to have_http_status(404) end @@ -702,6 +707,7 @@ describe API::Users do describe "DELETE /users/:id" do let!(:namespace) { user.namespace } + let!(:issue) { create(:issue, author: user) } before { admin } it "deletes user" do @@ -733,6 +739,25 @@ describe API::Users do expect(response).to have_http_status(404) end + + context "hard delete disabled" do + it "moves contributions to the ghost user" do + Sidekiq::Testing.inline! { delete api("/users/#{user.id}", admin) } + + expect(response).to have_http_status(204) + expect(issue.reload).to be_persisted + expect(issue.author.ghost?).to be_truthy + end + end + + context "hard delete enabled" do + it "removes contributions" do + Sidekiq::Testing.inline! { delete api("/users/#{user.id}?hard_delete=true", admin) } + + expect(response).to have_http_status(204) + expect(Issue.exists?(issue.id)).to be_falsy + end + end end describe "GET /user" do @@ -1110,83 +1135,6 @@ describe API::Users do end end - describe 'GET /users/: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 - context 'joined event' do - it 'returns the "joined" event' do - get api("/users/#{user.id}/events", user) - - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - - 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 - - context 'when there are multiple events from different projects' do - let(:second_note) { create(:note_on_issue, project: create(:empty_project)) } - let(:third_note) { create(:note_on_issue, project: project) } - - before do - second_note.project.add_user(user, :developer) - - [second_note, third_note].each do |note| - EventCreateService.new.leave_note(note, user) - end - end - - it 'returns events in the correct order (from newest to oldest)' do - get api("/users/#{user.id}/events", user) - - comment_events = json_response.select { |e| e['action_name'] == 'commented on' } - - expect(comment_events[0]['target_id']).to eq(third_note.id) - expect(comment_events[1]['target_id']).to eq(second_note.id) - expect(comment_events[2]['target_id']).to eq(note.id) - 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 - context "user activities", :redis do let!(:old_active_user) { create(:user, last_activity_on: Time.utc(2000, 1, 1)) } let!(:newly_active_user) { create(:user, last_activity_on: 2.days.ago.midday) } diff --git a/spec/requests/api/v3/branches_spec.rb b/spec/requests/api/v3/branches_spec.rb index 72f8fbe71fb..c88f7788697 100644 --- a/spec/requests/api/v3/branches_spec.rb +++ b/spec/requests/api/v3/branches_spec.rb @@ -47,19 +47,6 @@ describe API::V3::Branches do delete v3_api("/projects/#{project.id}/repository/branches/foobar", user) expect(response).to have_http_status(404) end - - it "removes protected branch" do - create(:protected_branch, project: project, name: branch_name) - delete v3_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 "does not remove HEAD branch" do - delete v3_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') - end end describe "DELETE /projects/:id/repository/merged_branches" do diff --git a/spec/requests/api/v3/commits_spec.rb b/spec/requests/api/v3/commits_spec.rb index 386f60065ad..4a4a5dc5c7c 100644 --- a/spec/requests/api/v3/commits_spec.rb +++ b/spec/requests/api/v3/commits_spec.rb @@ -386,7 +386,7 @@ describe API::V3::Commits do end it "returns status for CI" do - pipeline = project.ensure_pipeline('master', project.repository.commit.sha) + pipeline = project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) pipeline.update(status: 'success') get v3_api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) @@ -396,7 +396,7 @@ describe API::V3::Commits do end it "returns status for CI when pipeline is created" do - project.ensure_pipeline('master', project.repository.commit.sha) + project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) get v3_api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) diff --git a/spec/requests/api/v3/deploy_keys_spec.rb b/spec/requests/api/v3/deploy_keys_spec.rb index b61b2b618a6..94f4d93a8dc 100644 --- a/spec/requests/api/v3/deploy_keys_spec.rb +++ b/spec/requests/api/v3/deploy_keys_spec.rb @@ -105,6 +105,15 @@ describe API::V3::DeployKeys do expect(response).to have_http_status(201) end + + it 'accepts can_push parameter' do + key_attrs = attributes_for :write_access_key + + post v3_api("/projects/#{project.id}/#{path}", admin), key_attrs + + expect(response).to have_http_status(201) + expect(json_response['can_push']).to eq(true) + end end describe "DELETE /projects/:id/#{path}/:key_id" do diff --git a/spec/requests/api/v3/files_spec.rb b/spec/requests/api/v3/files_spec.rb index 5bcbb441979..378ca1720ff 100644 --- a/spec/requests/api/v3/files_spec.rb +++ b/spec/requests/api/v3/files_spec.rb @@ -53,7 +53,7 @@ describe API::V3::Files do let(:params) do { file_path: 'app/models/application.rb', - ref: 'master', + ref: 'master' } end @@ -263,7 +263,7 @@ describe API::V3::Files do let(:get_params) do { file_path: file_path, - ref: 'master', + ref: 'master' } end diff --git a/spec/requests/api/v3/groups_spec.rb b/spec/requests/api/v3/groups_spec.rb index 2f6f1bad0b8..98e8c954909 100644 --- a/spec/requests/api/v3/groups_spec.rb +++ b/spec/requests/api/v3/groups_spec.rb @@ -69,7 +69,7 @@ describe API::V3::Groups do storage_size: 702, repository_size: 123, lfs_objects_size: 234, - build_artifacts_size: 345, + build_artifacts_size: 345 }.stringify_keys project1.statistics.update!(attributes) @@ -176,7 +176,7 @@ describe API::V3::Groups do expect(json_response['path']).to eq(group1.path) expect(json_response['description']).to eq(group1.description) expect(json_response['visibility_level']).to eq(group1.visibility_level) - expect(json_response['avatar_url']).to eq(group1.avatar_url) + expect(json_response['avatar_url']).to eq(group1.avatar_url(only_path: false)) expect(json_response['web_url']).to eq(group1.web_url) expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled) expect(json_response['full_name']).to eq(group1.full_name) diff --git a/spec/requests/api/v3/project_hooks_spec.rb b/spec/requests/api/v3/project_hooks_spec.rb index a3a4c77d09d..1969d1c7f2b 100644 --- a/spec/requests/api/v3/project_hooks_spec.rb +++ b/spec/requests/api/v3/project_hooks_spec.rb @@ -58,7 +58,7 @@ describe API::ProjectHooks, 'ProjectHooks' 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['build_events']).to eq(hook.job_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) @@ -143,7 +143,7 @@ describe API::ProjectHooks, 'ProjectHooks' 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['build_events']).to eq(hook.job_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) diff --git a/spec/requests/api/v3/projects_spec.rb b/spec/requests/api/v3/projects_spec.rb index 5503882609f..47cca4275af 100644 --- a/spec/requests/api/v3/projects_spec.rb +++ b/spec/requests/api/v3/projects_spec.rb @@ -165,7 +165,7 @@ describe API::V3::Projects do expect(json_response).to satisfy do |response| response.one? do |entry| - entry.has_key?('permissions') && + entry.key?('permissions') && entry['name'] == project.name && entry['owner']['username'] == user.username end @@ -226,7 +226,7 @@ describe API::V3::Projects do storage_size: 702, repository_size: 123, lfs_objects_size: 234, - build_artifacts_size: 345, + build_artifacts_size: 345 } project4.statistics.update!(attributes) @@ -704,7 +704,7 @@ describe API::V3::Projects do 'name' => user.namespace.name, 'path' => user.namespace.path, 'kind' => user.namespace.kind, - 'full_path' => user.namespace.full_path, + 'full_path' => user.namespace.full_path }) end diff --git a/spec/requests/api/v3/system_hooks_spec.rb b/spec/requests/api/v3/system_hooks_spec.rb index 72c7d14b8ba..ae427541abb 100644 --- a/spec/requests/api/v3/system_hooks_spec.rb +++ b/spec/requests/api/v3/system_hooks_spec.rb @@ -31,8 +31,9 @@ describe API::V3::SystemHooks do expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.first['url']).to eq(hook.url) - expect(json_response.first['push_events']).to be true + expect(json_response.first['push_events']).to be false expect(json_response.first['tag_push_events']).to be false + expect(json_response.first['repository_update_events']).to be true end end end diff --git a/spec/requests/api/variables_spec.rb b/spec/requests/api/variables_spec.rb index 63d6d3001ac..83673864fe7 100644 --- a/spec/requests/api/variables_spec.rb +++ b/spec/requests/api/variables_spec.rb @@ -42,6 +42,7 @@ describe API::Variables do expect(response).to have_http_status(200) expect(json_response['value']).to eq(variable.value) + expect(json_response['protected']).to eq(variable.protected?) end it 'responds with 404 Not Found if requesting non-existing variable' do @@ -72,12 +73,13 @@ describe API::Variables do context 'authorized user with proper permissions' do it 'creates variable' do expect do - post api("/projects/#{project.id}/variables", user), key: 'TEST_VARIABLE_2', value: 'VALUE_2' + post api("/projects/#{project.id}/variables", user), key: 'TEST_VARIABLE_2', value: 'VALUE_2', protected: true end.to change{project.variables.count}.by(1) expect(response).to have_http_status(201) expect(json_response['key']).to eq('TEST_VARIABLE_2') expect(json_response['value']).to eq('VALUE_2') + expect(json_response['protected']).to be_truthy end it 'does not allow to duplicate variable key' do @@ -112,13 +114,14 @@ describe API::Variables do initial_variable = project.variables.first value_before = initial_variable.value - put api("/projects/#{project.id}/variables/#{variable.key}", user), value: 'VALUE_1_UP' + put api("/projects/#{project.id}/variables/#{variable.key}", user), value: 'VALUE_1_UP', protected: true updated_variable = project.variables.first expect(response).to have_http_status(200) expect(value_before).to eq(variable.value) expect(updated_variable.value).to eq('VALUE_1_UP') + expect(updated_variable).to be_protected end it 'responds with 404 Not Found if requesting non-existing variable' do diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index 108f73bb965..286de277ae7 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -185,7 +185,7 @@ describe Ci::API::Builds do { "key" => "CI_PIPELINE_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 }, + { "key" => "TRIGGER_KEY_1", "value" => "TRIGGER_VALUE_1", "public" => false } ) end end diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 6ca3ef18fe6..f018b48ceb2 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -5,76 +5,217 @@ describe 'Git HTTP requests', lib: true do include WorkhorseHelpers include UserActivitiesHelpers - it "gives WWW-Authenticate hints" do - clone_get('doesnt/exist.git') + shared_examples 'pulls require Basic HTTP Authentication' do + context "when no credentials are provided" do + it "responds to downloads with status 401 Unauthorized (no project existence information leak)" do + download(path) do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') + end + end + end - expect(response.header['WWW-Authenticate']).to start_with('Basic ') - end + context "when only username is provided" do + it "responds to downloads with status 401 Unauthorized" do + download(path, user: user.username) do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') + end + end + end - describe "User with no identities" do - let(:user) { create(:user) } - let(:project) { create(:project, :repository, path: 'project.git-project') } + context "when username and password are provided" do + context "when authentication fails" do + it "responds to downloads with status 401 Unauthorized" do + download(path, user: user.username, password: "wrong-password") do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') + end + end + 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) + context "when authentication succeeds" do + it "does not respond to downloads with status 401 Unauthorized" do + download(path, user: user.username, password: user.password) do |response| + expect(response).not_to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to be_nil end end end + end + end - 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 + shared_examples 'pushes require Basic HTTP Authentication' do + context "when no credentials are provided" do + it "responds to uploads with status 401 Unauthorized (no project existence information leak)" do + upload(path) do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') + end + end + end + + context "when only username is provided" do + it "responds to uploads with status 401 Unauthorized" do + upload(path, user: user.username) do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') + end + end + end + + context "when username and password are provided" do + context "when authentication fails" do + it "responds to uploads with status 401 Unauthorized" do + upload(path, user: user.username, password: "wrong-password") do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') 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) - end + context "when authentication succeeds" do + it "does not respond to uploads with status 401 Unauthorized" do + upload(path, user: user.username, password: user.password) do |response| + expect(response).not_to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to be_nil 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) + shared_examples_for 'pulls are allowed' do + it do + download(path, env) do |response| + expect(response).to have_http_status(:ok) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + end + end - download("/#{wiki.repository.path_with_namespace}.git") do |response| - json_body = ActiveSupport::JSON.decode(response.body) + shared_examples_for 'pushes are allowed' do + it do + upload(path, env) do |response| + expect(response).to have_http_status(:ok) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + end + end - 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) + describe "User with no identities" do + let(:user) { create(:user) } + + context "when the project doesn't exist" do + let(:path) { 'doesnt/exist.git' } + + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' + + context 'when authenticated' do + it 'rejects downloads and uploads with 404 Not Found' do + download_or_upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:not_found) + end end end + end + + context "when requesting the Wiki" do + let(:wiki) { ProjectWiki.new(project) } + let(:path) { "/#{wiki.repository.path_with_namespace}.git" } + + context "when the project is public" do + let(:project) { create(:project, :repository, :public, :wiki_enabled) } + + it_behaves_like 'pushes require Basic HTTP Authentication' + + context 'when unauthenticated' do + let(:env) { {} } - context 'but the repo is disabled' do - let(:project) { create(:project, :repository_disabled, :wiki_enabled) } - let(:wiki) { ProjectWiki.new(project) } - let(:path) { "/#{wiki.repository.path_with_namespace}.git" } + it_behaves_like 'pulls are allowed' - before do - project.team << [user, :developer] + it "responds to pulls with the wiki's repo" do + download(path) do |response| + json_body = ActiveSupport::JSON.decode(response.body) + + expect(json_body['RepoPath']).to include(wiki.repository.path_with_namespace) + end + end end - it 'allows clones' do - download(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(200) + context 'when authenticated' do + let(:env) { { user: user.username, password: user.password } } + + context 'and as a developer on the team' do + before do + project.team << [user, :developer] + end + + context 'but the repo is disabled' do + let(:project) { create(:project, :repository, :public, :repository_disabled, :wiki_enabled) } + + it_behaves_like 'pulls are allowed' + it_behaves_like 'pushes are allowed' + end + end + + context 'and not on the team' do + it_behaves_like 'pulls are allowed' + + it 'rejects pushes with 403 Forbidden' do + upload(path, env) do |response| + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_wiki_error(:write_to_wiki)) + end + end end end + end - it 'allows pushes' do - upload(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(200) + context "when the project is private" do + let(:project) { create(:project, :repository, :private, :wiki_enabled) } + + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' + + context 'when authenticated' do + context 'and as a developer on the team' do + before do + project.team << [user, :developer] + end + + context 'but the repo is disabled' do + let(:project) { create(:project, :repository, :private, :repository_disabled, :wiki_enabled) } + + it 'allows clones' do + download(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:ok) + end + end + + it 'pushes are allowed' do + upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:ok) + end + end + end + end + + context 'and not on the team' do + it 'rejects clones with 404 Not Found' do + download(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:not_found) + expect(response.body).to eq(git_access_error(:project_not_found)) + end + end + + it 'rejects pushes with 404 Not Found' do + upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:not_found) + expect(response.body).to eq(git_access_error(:project_not_found)) + end + end end end end @@ -84,49 +225,60 @@ describe 'Git HTTP requests', lib: true do let(:path) { "#{project.path_with_namespace}.git" } context "when the project is public" do - before do - project.update_attribute(:visibility_level, Project::PUBLIC) - end + let(:project) { create(:project, :repository, :public) } - 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 + it_behaves_like 'pushes require Basic HTTP Authentication' - it "uploads get status 401" do - upload(path, {}) do |response| - expect(response).to have_http_status(401) - end + context 'when not authenticated' do + let(:env) { {} } + + it_behaves_like 'pulls are allowed' end - context "with correct credentials" do + context "when authenticated" 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(403) + context 'as a developer on the team' do + before do + project.team << [user, :developer] 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_behaves_like 'pulls are allowed' + it_behaves_like 'pushes are allowed' - upload(path, env) do |response| - expect(response).to have_http_status(403) + context 'but git-receive-pack over HTTP is disabled in config' do + before do + allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false) + end + + it 'rejects pushes with 403 Forbidden' do + upload(path, env) do |response| + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_error(:receive_pack_disabled_over_http)) + end + end + end + + context 'but git-upload-pack over HTTP is disabled in config' do + it "rejects pushes with 403 Forbidden" do + allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false) + + download(path, env) do |response| + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_error(:upload_pack_disabled_over_http)) + end 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 'and not a member of the team' do + it_behaves_like 'pulls are allowed' - download(path, {}) do |response| - expect(response).to have_http_status(404) + it 'rejects pushes with 403 Forbidden' do + upload(path, env) do |response| + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(change_access_error(:push_code)) + end end end end @@ -141,66 +293,41 @@ describe 'Git HTTP requests', lib: true do context 'when the repo is public' do context 'but the repo is disabled' do - it 'does not allow to clone the repo' do - project = create(:project, :public, :repository_disabled) + let(:project) { create(:project, :public, :repository, :repository_disabled) } + let(:path) { "#{project.path_with_namespace}.git" } + let(:env) { {} } - download("#{project.path_with_namespace}.git", {}) do |response| - expect(response).to have_http_status(:unauthorized) - end - end + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' end context 'but the repo is enabled' do - it 'allows to clone the repo' do - project = create(:project, :public, :repository_enabled) + let(:project) { create(:project, :public, :repository, :repository_enabled) } + let(:path) { "#{project.path_with_namespace}.git" } + let(:env) { {} } - download("#{project.path_with_namespace}.git", {}) do |response| - expect(response).to have_http_status(:ok) - end - end + it_behaves_like 'pulls are allowed' end context 'but only project members are allowed' do - it 'does not allow to clone the repo' do - project = create(:project, :public, :repository_private) + let(:project) { create(:project, :public, :repository, :repository_private) } - download("#{project.path_with_namespace}.git", {}) do |response| - expect(response).to have_http_status(:unauthorized) - end - end + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' end end end context "when the project is private" do - before do - project.update_attribute(:visibility_level, Project::PRIVATE) - end - - 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 + let(:project) { create(:project, :repository, :private) } - it "responds with status 401 to uploads" do - upload(path, {}) do |response| - expect(response).to have_http_status(401) - end - end - end + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' context "when username and password are provided" do let(:env) { { user: user.username, password: 'nope' } } 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 IP banned" do it "responds with status 401" do expect(Rack::Attack::Allow2Ban).to receive(:filter).and_return(true) @@ -208,7 +335,7 @@ describe 'Git HTTP requests', lib: true do clone_get(path, env) - expect(response).to have_http_status(401) + expect(response).to have_http_status(:unauthorized) end end end @@ -222,37 +349,39 @@ describe 'Git HTTP requests', lib: true do end context "when the user is blocked" do - it "responds with status 401" do + it "rejects pulls with 401 Unauthorized" do user.block project.team << [user, :master] download(path, env) do |response| - expect(response).to have_http_status(401) + expect(response).to have_http_status(:unauthorized) end end - it "responds with status 401 for unknown projects (no project existence information leak)" do + it "rejects pulls with 401 Unauthorized for unknown projects (no project existence information leak)" do user.block download('doesnt/exist.git', env) do |response| - expect(response).to have_http_status(401) + expect(response).to have_http_status(:unauthorized) end end end 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) + it "resets the IP in Rack Attack on download" do + expect(Rack::Attack::Allow2Ban).to receive(:reset).twice - expect(response).to have_http_status(200) - expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + download(path, env) do + expect(response).to have_http_status(:ok) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end end - it "uploads get status 200" do - upload(path, env) do |response| - expect(response).to have_http_status(200) + it "resets the IP in Rack Attack on upload" do + expect(Rack::Attack::Allow2Ban).to receive(:reset).twice + + upload(path, env) do + expect(response).to have_http_status(:ok) expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) end end @@ -272,56 +401,43 @@ describe 'Git HTTP requests', lib: true do @token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api") 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 200" do - push_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token + let(:path) { "#{project.path_with_namespace}.git" } + let(:env) { { user: 'oauth2', password: @token.token } } - expect(response).to have_http_status(200) - end + it_behaves_like 'pulls are allowed' + it_behaves_like 'pushes are allowed' end context 'when user has 2FA enabled' do let(:user) { create(:user, :two_factor) } let(:access_token) { create(:personal_access_token, user: user) } + let(:path) { "#{project.path_with_namespace}.git" } 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) + it 'rejects pulls with 2FA error message' do + download(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:unauthorized) 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) + upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:unauthorized) 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 + let(:env) { { user: user.username, password: access_token.token } } - 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 + it_behaves_like 'pulls are allowed' + it_behaves_like 'pushes are allowed' end end @@ -357,15 +473,15 @@ describe 'Git HTTP requests', lib: true do end context "when the user doesn't have access to the project" do - it "downloads get status 404" do + it "pulls get status 404" do download(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(404) + expect(response).to have_http_status(:not_found) 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) + expect(response).to have_http_status(:not_found) end end end @@ -373,28 +489,41 @@ describe 'Git HTTP requests', lib: true do end context "when a gitlab ci token is provided" do + let(:project) { create(:project, :repository) } let(:build) { create(:ci_build, :running) } - let(:project) { build.project } let(:other_project) { create(:empty_project) } - 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(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: 'gitlab-ci-token', password: build.token + before do + build.update!(project: project) # can't associate it on factory create + end - expect(response).to have_http_status(401) + context 'when build created by system is authenticated' do + let(:path) { "#{project.path_with_namespace}.git" } + let(:env) { { user: 'gitlab-ci-token', password: build.token } } + + it_behaves_like 'pulls are allowed' + + # A non-401 here is not an information leak since the system is + # "authenticated" as CI using the correct token. It does not have + # push access, so pushes should be rejected as forbidden, and giving + # a reason is fine. + # + # We know for sure it is not an information leak since pulls using + # the build token must be allowed. + it "rejects pushes with 403 Forbidden" do + push_get(path, env) + + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_error(:upload)) 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 + # We are "authenticated" as CI using a valid token here. But we are + # not authorized to see any other project, so return "not found". + it "rejects pulls for other project with 404 Not Found" do + clone_get("#{other_project.path_with_namespace}.git", env) - expect(response).to have_http_status(404) + expect(response).to have_http_status(:not_found) + expect(response.body).to eq(git_access_error(:project_not_found)) end end @@ -405,31 +534,27 @@ describe 'Git HTTP requests', lib: true do end shared_examples 'can download code only' do - it 'downloads get status 200' do - allow_any_instance_of(Repository). - to receive(:exists?).and_return(true) - - clone_get "#{project.path_with_namespace}.git", - user: 'gitlab-ci-token', password: build.token + let(:path) { "#{project.path_with_namespace}.git" } + let(:env) { { 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_behaves_like 'pulls are allowed' - it 'downloads from non-existing repository and gets 403' do - allow_any_instance_of(Repository). - to receive(:exists?).and_return(false) + context 'when the repo does not exist' do + let(:project) { create(:empty_project) } - clone_get "#{project.path_with_namespace}.git", - user: 'gitlab-ci-token', password: build.token + it 'rejects pulls with 403 Forbidden' do + clone_get path, env - expect(response).to have_http_status(403) + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_error(:no_repo)) + end end - it 'uploads get status 403' do - push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token + it 'rejects pushes with 403 Forbidden' do + push_get path, env - expect(response).to have_http_status(401) + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_error(:upload)) end end @@ -441,7 +566,7 @@ describe 'Git HTTP requests', lib: true do 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(403) + expect(response).to have_http_status(:forbidden) end end @@ -453,91 +578,93 @@ describe 'Git HTTP requests', lib: true do 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) + expect(response).to have_http_status(:not_found) 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 + let(:project) { create(:project, :repository, :public, path: 'project.git-project') } + + 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' } } + before { get path, params } - it "redirects to the sign-in page" do - expect(response).to redirect_to(new_user_session_path) + it "redirects to the sign-in page" do + expect(response).to redirect_to(new_user_session_path) + 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 + let(:project) { create(:project, :repository, :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('b83d6e391c22777fca1ed3012fce84f633d7fed0', 'info/refs') do + Gitlab::Git::Blob.find(project.repository, 'master', 'bar/branch-test.txt') + 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') + get "/#{project.path_with_namespace}/blob/master/info/refs" end - get "/#{project.path_with_namespace}/blob/master/info/refs" + it "returns the file" do + expect(response).to have_http_status(:ok) + end end - it "returns the file" do - expect(response).to have_http_status(200) - 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(:not_found) + end end end end @@ -546,6 +673,7 @@ describe 'Git HTTP requests', lib: true do describe "User with LDAP identity" do let(:user) { create(:omniauth_user, extern_uid: dn) } let(:dn) { 'uid=john,ou=people,dc=example,dc=com' } + let(:path) { 'doesnt/exist.git' } before do allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true) @@ -553,44 +681,36 @@ describe 'Git HTTP requests', lib: true do allow(Gitlab::LDAP::Authentication).to receive(:login).with(user.username, user.password).and_return(user) end - 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 - - 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 + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' 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) + it "responds with status 404 Not Found" do + download(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:not_found) end end end context "when the project exists" do - let(:project) { create(:project, path: 'project.git-project') } + let(:project) { create(:project, :repository) } + let(:path) { "#{project.full_path}.git" } + let(:env) { { user: user.username, password: user.password } } - before do - project.team << [user, :master] - end + context 'and the user is on the team' do + before do + project.team << [user, :master] + end - it "responds with status 200" do - clone_get(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(200) + it "responds with status 200" do + clone_get(path, env) do |response| + expect(response).to have_http_status(200) + end end + + it_behaves_like 'pulls are allowed' + it_behaves_like 'pushes are allowed' end end end diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb index a3e7844b2f3..e056353fa6f 100644 --- a/spec/requests/jwt_controller_spec.rb +++ b/spec/requests/jwt_controller_spec.rb @@ -41,6 +41,19 @@ describe JwtController do it { expect(response).to have_http_status(401) } end + + context 'using personal access tokens' do + let(:user) { create(:user) } + let(:pat) { create(:personal_access_token, user: user, scopes: ['read_registry']) } + let(:headers) { { authorization: credentials('personal_access_token', pat.token) } } + + subject! { get '/jwt/auth', parameters, headers } + + it 'authenticates correctly' do + expect(response).to have_http_status(200) + expect(service_class).to have_received(:new).with(nil, user, parameters) + end + end end context 'using User login' do diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb index 5d495bc9e7d..697b150ab34 100644 --- a/spec/requests/lfs_http_spec.rb +++ b/spec/requests/lfs_http_spec.rb @@ -425,7 +425,7 @@ describe 'Git LFS API and storage' do 'size' => sample_size, 'error' => { 'code' => 404, - 'message' => "Object does not exist on the server or you don't have permissions to access it", + 'message' => "Object does not exist on the server or you don't have permissions to access it" } } ] @@ -456,7 +456,7 @@ describe 'Git LFS API and storage' do 'size' => 1575078, 'error' => { 'code' => 404, - 'message' => "Object does not exist on the server or you don't have permissions to access it", + 'message' => "Object does not exist on the server or you don't have permissions to access it" } } ] @@ -493,7 +493,7 @@ describe 'Git LFS API and storage' do 'size' => 1575078, 'error' => { 'code' => 404, - 'message' => "Object does not exist on the server or you don't have permissions to access it", + 'message' => "Object does not exist on the server or you don't have permissions to access it" } }, { @@ -759,8 +759,8 @@ describe 'Git LFS API and storage' do 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) + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end @@ -769,8 +769,9 @@ describe 'Git LFS API and storage' do 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) + # I'm not sure what this tests that is different from the previous test + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end end @@ -778,8 +779,8 @@ describe 'Git LFS API and storage' do 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) + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end end @@ -979,8 +980,8 @@ describe 'Git LFS API and storage' do put_authorize end - it 'responds with 401' do - expect(response).to have_http_status(401) + it 'responds with 403 (not 404 because the build user can read the project)' do + expect(response).to have_http_status(403) end end @@ -993,8 +994,8 @@ describe 'Git LFS API and storage' do put_authorize end - it 'responds with 401' do - expect(response).to have_http_status(401) + it 'responds with 404 (do not leak non-public project existence)' do + expect(response).to have_http_status(404) end end end @@ -1006,8 +1007,8 @@ describe 'Git LFS API and storage' do put_authorize end - it 'responds with 401' do - expect(response).to have_http_status(401) + it 'responds with 404 (do not leak non-public project existence)' do + expect(response).to have_http_status(404) end end end @@ -1079,8 +1080,8 @@ describe 'Git LFS API and storage' do 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) + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end @@ -1089,8 +1090,9 @@ describe 'Git LFS API and storage' do 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) + # I'm not sure what this tests that is different from the previous test + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end end @@ -1098,8 +1100,8 @@ describe 'Git LFS API and storage' do 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) + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end end diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb index 75d8fc92a43..0721c1452c5 100644 --- a/spec/requests/openid_connect_spec.rb +++ b/spec/requests/openid_connect_spec.rb @@ -61,7 +61,7 @@ describe 'OpenID Connect requests' do email: private_email.email, public_email: public_email.email, website_url: 'https://example.com', - avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png"), + avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png") ) end @@ -79,7 +79,11 @@ describe 'OpenID Connect requests' do 'email_verified' => true, 'website' => 'https://example.com', 'profile' => 'http://localhost/alice', +<<<<<<< HEAD 'picture' => "http://localhost/uploads/system/user/avatar/#{user.id}/dk.png", +======= + 'picture' => "http://localhost/uploads/user/avatar/#{user.id}/dk.png" +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 }) end end @@ -98,7 +102,7 @@ describe 'OpenID Connect requests' do expect(@payload['sub']).to eq hashed_subject end - it 'includes the time of the last authentication' do + it 'includes the time of the last authentication', :redis do expect(@payload['auth_time']).to eq user.current_sign_in_at.to_i end diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb index 33940f70b1c..d4d3c9478a0 100644 --- a/spec/requests/projects/cycle_analytics_events_spec.rb +++ b/spec/requests/projects/cycle_analytics_events_spec.rb @@ -9,8 +9,6 @@ describe 'cycle analytics events', api: true do before do project.team << [user, :developer] - allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) - 3.times do |count| Timecop.freeze(Time.now + count.days) do create_cycle @@ -121,9 +119,9 @@ describe 'cycle analytics events', api: true do def create_cycle milestone = create(:milestone, project: project) issue.update(milestone: milestone) - mr = create_merge_request_closing_issue(issue) + mr = create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}") - pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha) + pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) pipeline.run create(:ci_build, pipeline: pipeline, status: :success, author: user) diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb index e5fc0b676af..179fc9733ad 100644 --- a/spec/routing/admin_routing_spec.rb +++ b/spec/routing/admin_routing_spec.rb @@ -103,6 +103,18 @@ describe Admin::HooksController, "routing" do end end +# admin_hook_hook_log_retry GET /admin/hooks/:hook_id/hook_logs/:id/retry(.:format) admin/hook_logs#retry +# admin_hook_hook_log GET /admin/hooks/:hook_id/hook_logs/:id(.:format) admin/hook_logs#show +describe Admin::HookLogsController, 'routing' do + it 'to #retry' do + expect(get('/admin/hooks/1/hook_logs/1/retry')).to route_to('admin/hook_logs#retry', hook_id: '1', id: '1') + end + + it 'to #show' do + expect(get('/admin/hooks/1/hook_logs/1')).to route_to('admin/hook_logs#show', hook_id: '1', id: '1') + end +end + # admin_logs GET /admin/logs(.:format) admin/logs#show describe Admin::LogsController, "routing" do it "to #show" do diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index a391c046f92..0a6778ae2ef 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -201,10 +201,12 @@ describe 'project routing' do # POST /:project_id/deploy_keys(.:format) deploy_keys#create # new_project_deploy_key GET /:project_id/deploy_keys/new(.:format) deploy_keys#new # project_deploy_key GET /:project_id/deploy_keys/:id(.:format) deploy_keys#show + # edit_project_deploy_key GET /:project_id/deploy_keys/:id/edit(.:format) deploy_keys#edit + # project_deploy_key PATCH /:project_id/deploy_keys/:id(.:format) deploy_keys#update # DELETE /:project_id/deploy_keys/:id(.:format) deploy_keys#destroy describe Projects::DeployKeysController, 'routing' do it_behaves_like 'RESTful project resources' do - let(:actions) { [:index, :new, :create] } + let(:actions) { [:index, :new, :create, :edit, :update] } let(:controller) { 'deploy_keys' } end end @@ -349,6 +351,18 @@ describe 'project routing' do end end + # retry_namespace_project_hook_hook_log GET /:project_id/hooks/:hook_id/hook_logs/:id/retry(.:format) projects/hook_logs#retry + # namespace_project_hook_hook_log GET /:project_id/hooks/:hook_id/hook_logs/:id(.:format) projects/hook_logs#show + describe Projects::HookLogsController, 'routing' do + it 'to #retry' do + expect(get('/gitlab/gitlabhq/hooks/1/hook_logs/1/retry')).to route_to('projects/hook_logs#retry', namespace_id: 'gitlab', project_id: 'gitlabhq', hook_id: '1', id: '1') + end + + it 'to #show' do + expect(get('/gitlab/gitlabhq/hooks/1/hook_logs/1')).to route_to('projects/hook_logs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', hook_id: '1', id: '1') + end + end + # project_commit GET /:project_id/commit/:id(.:format) commit#show {id: /\h{7,40}/, project_id: /[^\/]+/} describe Projects::CommitController, 'routing' do it 'to #show' do diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index 9f6defe1450..a62af13cf0c 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -151,6 +151,10 @@ describe ProfilesController, "routing" do expect(put("/profile/reset_private_token")).to route_to('profiles#reset_private_token') end + it "to #reset_rss_token" do + expect(put("/profile/reset_rss_token")).to route_to('profiles#reset_rss_token') + end + it "to #show" do expect(get("/profile")).to route_to('profiles#show') end @@ -249,17 +253,34 @@ describe RootController, 'routing' do end end -# new_user_session GET /users/sign_in(.:format) devise/sessions#new -# user_session POST /users/sign_in(.:format) devise/sessions#create -# destroy_user_session DELETE /users/sign_out(.:format) devise/sessions#destroy -# user_omniauth_authorize /users/auth/:provider(.:format) omniauth_callbacks#passthru -# user_omniauth_callback /users/auth/:action/callback(.:format) omniauth_callbacks#(?-mix:(?!)) -# user_password POST /users/password(.:format) devise/passwords#create -# new_user_password GET /users/password/new(.:format) devise/passwords#new -# edit_user_password GET /users/password/edit(.:format) devise/passwords#edit -# PUT /users/password(.:format) devise/passwords#update describe "Authentication", "routing" do - # pending + it "GET /users/sign_in" do + expect(get("/users/sign_in")).to route_to('sessions#new') + end + + it "POST /users/sign_in" do + expect(post("/users/sign_in")).to route_to('sessions#create') + end + + it "DELETE /users/sign_out" do + expect(delete("/users/sign_out")).to route_to('sessions#destroy') + end + + it "POST /users/password" do + expect(post("/users/password")).to route_to('passwords#create') + end + + it "GET /users/password/new" do + expect(get("/users/password/new")).to route_to('passwords#new') + end + + it "GET /users/password/edit" do + expect(get("/users/password/edit")).to route_to('passwords#edit') + end + + it "PUT /users/password" do + expect(put("/users/password")).to route_to('passwords#update') + end end describe "Groups", "routing" do diff --git a/spec/rubocop/cop/activerecord_serialize_spec.rb b/spec/rubocop/cop/activerecord_serialize_spec.rb new file mode 100644 index 00000000000..5bd7e5fa926 --- /dev/null +++ b/spec/rubocop/cop/activerecord_serialize_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +require 'rubocop' +require 'rubocop/rspec/support' +require_relative '../../../rubocop/cop/activerecord_serialize' + +describe RuboCop::Cop::ActiverecordSerialize do + include CopHelper + + subject(:cop) { described_class.new } + + context 'inside the app/models directory' do + it 'registers an offense when serialize is used' do + allow(cop).to receive(:in_model?).and_return(true) + + inspect_source(cop, 'serialize :foo') + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([1]) + end + end + end + + context 'outside the app/models directory' do + it 'does nothing' do + allow(cop).to receive(:in_model?).and_return(false) + + inspect_source(cop, 'serialize :foo') + + expect(cop.offenses).to be_empty + end + end +end diff --git a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb new file mode 100644 index 00000000000..968dcd6232e --- /dev/null +++ b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' +require 'rubocop' +require 'rubocop/rspec/support' +require_relative '../../../../rubocop/cop/migration/update_column_in_batches' + +describe RuboCop::Cop::Migration::UpdateColumnInBatches do + let(:cop) { described_class.new } + let(:tmp_rails_root) { Rails.root.join('tmp', 'rails_root') } + let(:migration_code) do + <<-END + def up + update_column_in_batches(:projects, :name, "foo") do |table, query| + query.where(table[:name].eq(nil)) + end + end + END + end + + before do + allow(cop).to receive(:rails_root).and_return(tmp_rails_root) + end + after do + FileUtils.rm_rf(tmp_rails_root) + end + + context 'outside of a migration' do + it 'does not register any offenses' do + inspect_source(cop, migration_code) + + expect(cop.offenses).to be_empty + end + end + + let(:spec_filepath) { tmp_rails_root.join('spec', 'migrations', 'my_super_migration_spec.rb') } + + shared_context 'with a migration file' do + before do + FileUtils.mkdir_p(File.dirname(migration_filepath)) + @migration_file = File.new(migration_filepath, 'w+') + end + after do + @migration_file.close + end + end + + shared_examples 'a migration file with no spec file' do + include_context 'with a migration file' + + let(:relative_spec_filepath) { Pathname.new(spec_filepath).relative_path_from(tmp_rails_root) } + + it 'registers an offense when using update_column_in_batches' do + inspect_source(cop, migration_code, @migration_file) + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([2]) + expect(cop.offenses.first.message). + to include("`#{relative_spec_filepath}`") + end + end + end + + shared_examples 'a migration file with a spec file' do + include_context 'with a migration file' + + before do + FileUtils.mkdir_p(File.dirname(spec_filepath)) + @spec_file = File.new(spec_filepath, 'w+') + end + after do + @spec_file.close + end + + it 'does not register any offenses' do + inspect_source(cop, migration_code, @migration_file) + + expect(cop.offenses).to be_empty + end + end + + context 'in a migration' do + let(:migration_filepath) { tmp_rails_root.join('db', 'migrate', '20121220064453_my_super_migration.rb') } + + it_behaves_like 'a migration file with no spec file' + it_behaves_like 'a migration file with a spec file' + end + + context 'in a post migration' do + let(:migration_filepath) { tmp_rails_root.join('db', 'post_migrate', '20121220064453_my_super_migration.rb') } + + it_behaves_like 'a migration file with no spec file' + it_behaves_like 'a migration file with a spec file' + end +end diff --git a/spec/rubocop/cop/polymorphic_associations_spec.rb b/spec/rubocop/cop/polymorphic_associations_spec.rb new file mode 100644 index 00000000000..49959aa6419 --- /dev/null +++ b/spec/rubocop/cop/polymorphic_associations_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +require 'rubocop' +require 'rubocop/rspec/support' +require_relative '../../../rubocop/cop/polymorphic_associations' + +describe RuboCop::Cop::PolymorphicAssociations do + include CopHelper + + subject(:cop) { described_class.new } + + context 'inside the app/models directory' do + it 'registers an offense when polymorphic: true is used' do + allow(cop).to receive(:in_model?).and_return(true) + + inspect_source(cop, 'belongs_to :foo, polymorphic: true') + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([1]) + end + end + end + + context 'outside the app/models directory' do + it 'does nothing' do + allow(cop).to receive(:in_model?).and_return(false) + + inspect_source(cop, 'belongs_to :foo, polymorphic: true') + + expect(cop.offenses).to be_empty + end + end +end diff --git a/spec/rubocop/cop/redirect_with_status_spec.rb b/spec/rubocop/cop/redirect_with_status_spec.rb new file mode 100644 index 00000000000..5ad63567f84 --- /dev/null +++ b/spec/rubocop/cop/redirect_with_status_spec.rb @@ -0,0 +1,86 @@ +require 'spec_helper' + +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../rubocop/cop/redirect_with_status' + +describe RuboCop::Cop::RedirectWithStatus do + include CopHelper + + subject(:cop) { described_class.new } + let(:controller_fixture_without_status) do + %q( + class UserController < ApplicationController + def show + user = User.find(params[:id]) + redirect_to user_path if user.name == 'John Wick' + end + + def destroy + user = User.find(params[:id]) + + if user.destroy + redirect_to root_path + else + render :show + end + end + end + ) + end + + let(:controller_fixture_with_status) do + %q( + class UserController < ApplicationController + def show + user = User.find(params[:id]) + redirect_to user_path if user.name == 'John Wick' + end + + def destroy + user = User.find(params[:id]) + + if user.destroy + redirect_to root_path, status: 302 + else + render :show + end + end + end + ) + end + + context 'in controller' do + before do + allow(cop).to receive(:in_controller?).and_return(true) + end + + it 'registers an offense when a "destroy" action uses "redirect_to" without "status"' do + inspect_source(cop, controller_fixture_without_status) + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([12]) # 'redirect_to' is located on 12th line in controller_fixture. + expect(cop.highlights).to eq(['redirect_to']) + end + end + + it 'does not register an offense when a "destroy" action uses "redirect_to" with "status"' do + inspect_source(cop, controller_fixture_with_status) + + aggregate_failures do + expect(cop.offenses.size).to eq(0) + end + end + end + + context 'outside of controller' do + it 'registers no offense' do + inspect_source(cop, controller_fixture_without_status) + inspect_source(cop, controller_fixture_with_status) + + expect(cop.offenses.size).to eq(0) + end + end +end diff --git a/spec/serializers/analytics_issue_entity_spec.rb b/spec/serializers/analytics_issue_entity_spec.rb index 68086216ba9..75d606d5eb3 100644 --- a/spec/serializers/analytics_issue_entity_spec.rb +++ b/spec/serializers/analytics_issue_entity_spec.rb @@ -9,7 +9,7 @@ describe AnalyticsIssueEntity do iid: "1", id: "1", created_at: "2016-11-12 15:04:02.948604", - author: user, + author: user } end diff --git a/spec/serializers/analytics_issue_serializer_spec.rb b/spec/serializers/analytics_issue_serializer_spec.rb index ba24cf8e481..7c14c198a74 100644 --- a/spec/serializers/analytics_issue_serializer_spec.rb +++ b/spec/serializers/analytics_issue_serializer_spec.rb @@ -16,7 +16,7 @@ describe AnalyticsIssueSerializer do iid: "1", id: "1", created_at: "2016-11-12 15:04:02.948604", - author: user, + author: user } end diff --git a/spec/serializers/build_action_entity_spec.rb b/spec/serializers/build_action_entity_spec.rb index 059deba5416..15720d86583 100644 --- a/spec/serializers/build_action_entity_spec.rb +++ b/spec/serializers/build_action_entity_spec.rb @@ -1,26 +1,26 @@ require 'spec_helper' describe BuildActionEntity do - let(:build) { create(:ci_build, name: 'test_build') } + let(:job) { create(:ci_build, name: 'test_job') } let(:request) { double('request') } let(:entity) do - described_class.new(build, request: spy('request')) + described_class.new(job, request: spy('request')) end describe '#as_json' do subject { entity.as_json } - it 'contains original build name' do - expect(subject[:name]).to eq 'test_build' + it 'contains original job name' do + expect(subject[:name]).to eq 'test_job' end it 'contains path to the action play' do - expect(subject[:path]).to include "builds/#{build.id}/play" + expect(subject[:path]).to include "jobs/#{job.id}/play" end it 'contains whether it is playable' do - expect(subject[:playable]).to eq build.playable? + expect(subject[:playable]).to eq job.playable? end end end diff --git a/spec/serializers/build_artifact_entity_spec.rb b/spec/serializers/build_artifact_entity_spec.rb index 2fc60aa9de6..ad0d3d3839e 100644 --- a/spec/serializers/build_artifact_entity_spec.rb +++ b/spec/serializers/build_artifact_entity_spec.rb @@ -1,22 +1,32 @@ require 'spec_helper' describe BuildArtifactEntity do - let(:build) { create(:ci_build, name: 'test:build') } + let(:job) { create(:ci_build, name: 'test:job', artifacts_expire_at: 1.hour.from_now) } let(:entity) do - described_class.new(build, request: double) + described_class.new(job, request: double) end describe '#as_json' do subject { entity.as_json } - it 'contains build name' do - expect(subject[:name]).to eq 'test:build' + it 'contains job name' do + expect(subject[:name]).to eq 'test:job' end - it 'contains path to the artifacts' do + it 'exposes information about expiration of artifacts' do + expect(subject).to include(:expired, :expire_at) + end + + it 'contains paths to the artifacts' do expect(subject[:path]) - .to include "builds/#{build.id}/artifacts/download" + .to include "jobs/#{job.id}/artifacts/download" + + expect(subject[:keep_path]) + .to include "jobs/#{job.id}/artifacts/keep" + + expect(subject[:browse_path]) + .to include "jobs/#{job.id}/artifacts/browse" end end end diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb new file mode 100644 index 00000000000..e2511e8968c --- /dev/null +++ b/spec/serializers/build_details_entity_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe BuildDetailsEntity do + set(:user) { create(:admin) } + + it 'inherits from BuildEntity' do + expect(described_class).to be < BuildEntity + end + + describe '#as_json' do + let(:project) { create(:project, :repository) } + let!(:build) { create(:ci_build, :failed, project: project) } + let(:request) { double('request') } + let(:entity) { described_class.new(build, request: request, current_user: user, project: project) } + subject { entity.as_json } + + before do + allow(request).to receive(:current_user).and_return(user) + end + + context 'when the user has access to issues and merge requests' do + let!(:merge_request) do + create(:merge_request, source_project: project, source_branch: build.ref) + end + + before do + allow(build).to receive(:merge_request).and_return(merge_request) + end + + it 'contains the needed key value pairs' do + expect(subject).to include(:coverage, :erased_at, :duration) + expect(subject).to include(:artifacts, :runner, :pipeline) + expect(subject).to include(:raw_path, :merge_request) + expect(subject).to include(:new_issue_path) + end + + it 'exposes details of the merge request' do + expect(subject[:merge_request]).to include(:iid, :path) + end + + context 'when the build has been erased' do + let!(:build) { create(:ci_build, :erasable, project: project) } + + it 'exposes the user whom erased the build' do + expect(subject).to include(:erase_path) + end + end + + context 'when the build has been erased' do + let!(:build) { create(:ci_build, erased_at: Time.now, project: project, erased_by: user) } + + it 'exposes the user whom erased the build' do + expect(subject).to include(:erased_by) + end + end + end + + context 'when the user can only read the build' do + let(:user) { create(:user) } + + it "won't display the paths to issues and merge requests" do + expect(subject['new_issue_path']).to be_nil + expect(subject['merge_request_path']).to be_nil + end + end + end +end diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb index b5eb84ae43b..46d43a80ef7 100644 --- a/spec/serializers/build_entity_spec.rb +++ b/spec/serializers/build_entity_spec.rb @@ -2,7 +2,8 @@ require 'spec_helper' describe BuildEntity do let(:user) { create(:user) } - let(:build) { create(:ci_build) } + let(:build) { create(:ci_build, :failed) } + let(:project) { build.project } let(:request) { double('request') } before do @@ -17,6 +18,7 @@ describe BuildEntity do it 'contains paths to build page and retry action' do expect(subject).to include(:build_path, :retry_path) + expect(subject[:retry_path]).not_to be_nil end it 'does not contain sensitive information' do @@ -52,7 +54,10 @@ describe BuildEntity do context 'when user is allowed to trigger action' do before do - build.project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end it 'contains path to play action' do diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb index e73fbe190ca..ed89fccc3d0 100644 --- a/spec/serializers/deploy_key_entity_spec.rb +++ b/spec/serializers/deploy_key_entity_spec.rb @@ -12,27 +12,44 @@ describe DeployKeyEntity do let(:entity) { described_class.new(deploy_key, user: user) } - it 'returns deploy keys with projects a user can read' do - expected_result = { - id: deploy_key.id, - user_id: deploy_key.user_id, - title: deploy_key.title, - fingerprint: deploy_key.fingerprint, - can_push: deploy_key.can_push, - destroyed_when_orphaned: true, - almost_orphaned: false, - created_at: deploy_key.created_at, - updated_at: deploy_key.updated_at, - projects: [ - { - id: project.id, - name: project.name, - full_path: namespace_project_path(project.namespace, project), - full_name: project.full_name - } - ] - } - - expect(entity.as_json).to eq(expected_result) + describe 'returns deploy keys with projects a user can read' do + let(:expected_result) do + { + id: deploy_key.id, + user_id: deploy_key.user_id, + title: deploy_key.title, + fingerprint: deploy_key.fingerprint, + can_push: deploy_key.can_push, + destroyed_when_orphaned: true, + almost_orphaned: false, + created_at: deploy_key.created_at, + updated_at: deploy_key.updated_at, + can_edit: false, + projects: [ + { + id: project.id, + name: project.name, + full_path: namespace_project_path(project.namespace, project), + full_name: project.full_name + } + ] + } + end + + it { expect(entity.as_json).to eq(expected_result) } + end + + describe 'returns can_edit true if user is a master of project' do + before do + project.add_master(user) + end + + it { expect(entity.as_json).to include(can_edit: true) } + end + + describe 'returns can_edit true if a user admin' do + let(:user) { create(:user, :admin) } + + it { expect(entity.as_json).to include(can_edit: true) } end end diff --git a/spec/serializers/merge_request_entity_spec.rb b/spec/serializers/merge_request_entity_spec.rb index bb6e83ae4bd..e8f82a483a5 100644 --- a/spec/serializers/merge_request_entity_spec.rb +++ b/spec/serializers/merge_request_entity_spec.rb @@ -26,7 +26,11 @@ describe MergeRequestEntity do pipeline = build_stubbed(:ci_pipeline) allow(resource).to receive(:head_pipeline).and_return(pipeline) +<<<<<<< HEAD pipeline_payload = PipelineEntity +======= + pipeline_payload = PipelineDetailsEntity +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 .represent(pipeline, request: req) .as_json @@ -65,6 +69,26 @@ describe MergeRequestEntity do .to eq(resource.merge_commit_message(include_description: true)) end +<<<<<<< HEAD +======= + describe 'new_blob_path' do + context 'when user can push to project' do + it 'returns path' do + project.add_developer(user) + + expect(subject[:new_blob_path]) + .to eq("/#{resource.project.full_path}/new/#{resource.source_branch}") + end + end + + context 'when user cannot push to project' do + it 'returns nil' do + expect(subject[:new_blob_path]).to be_nil + end + end + end + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 describe 'diff_head_sha' do before do allow(resource).to receive(:diff_head_sha) { 'sha' } diff --git a/spec/serializers/pipeline_details_entity_spec.rb b/spec/serializers/pipeline_details_entity_spec.rb new file mode 100644 index 00000000000..03cc5ae9b63 --- /dev/null +++ b/spec/serializers/pipeline_details_entity_spec.rb @@ -0,0 +1,120 @@ +require 'spec_helper' + +describe PipelineDetailsEntity do + set(:user) { create(:user) } + let(:request) { double('request') } + + it 'inherrits from PipelineEntity' do + expect(described_class).to be < PipelineEntity + end + + before do + allow(request).to receive(:current_user).and_return(user) + end + + let(:entity) do + described_class.represent(pipeline, request: request) + end + + describe '#as_json' do + subject { entity.as_json } + + context 'when pipeline is empty' do + let(:pipeline) { create(:ci_empty_pipeline) } + + it 'contains details' do + expect(subject).to include :details + expect(subject[:details]) + .to include :duration, :finished_at + expect(subject[:details]) + .to include :stages, :artifacts, :manual_actions + expect(subject[:details][:status]).to include :icon, :favicon, :text, :label + end + + it 'contains flags' do + expect(subject).to include :flags + expect(subject[:flags]) + .to include :latest, :stuck, + :yaml_errors, :retryable, :cancelable + end + end + + context 'when pipeline is retryable' do + let(:project) { create(:empty_project) } + + let(:pipeline) do + create(:ci_pipeline, status: :success, project: project) + end + + before do + create(:ci_build, :failed, pipeline: pipeline) + end + + context 'user has ability to retry pipeline' do + before { project.team << [user, :developer] } + + it 'retryable flag is true' do + expect(subject[:flags][:retryable]).to eq true + end + end + + context 'user does not have ability to retry pipeline' do + it 'retryable flag is false' do + expect(subject[:flags][:retryable]).to eq false + end + end + end + + context 'when pipeline is cancelable' do + let(:project) { create(:empty_project) } + + let(:pipeline) do + create(:ci_pipeline, status: :running, project: project) + end + + before do + create(:ci_build, :pending, pipeline: pipeline) + end + + context 'user has ability to cancel pipeline' do + before { project.add_developer(user) } + + it 'cancelable flag is true' do + expect(subject[:flags][:cancelable]).to eq true + end + end + + context 'user does not have ability to cancel pipeline' do + it 'cancelable flag is false' do + expect(subject[:flags][:cancelable]).to eq false + end + end + end + + context 'when pipeline has YAML errors' do + let(:pipeline) do + create(:ci_pipeline, config: { rspec: { invalid: :value } }) + end + + it 'contains information about error' do + expect(subject[:yaml_errors]).to be_present + end + + it 'contains flag that indicates there are errors' do + expect(subject[:flags][:yaml_errors]).to be true + end + end + + context 'when pipeline does not have YAML errors' do + let(:pipeline) { create(:ci_empty_pipeline) } + + it 'does not contain field that normally holds an error' do + expect(subject).not_to have_key(:yaml_errors) + end + + it 'contains flag that indicates there are no errors' do + expect(subject[:flags][:yaml_errors]).to be false + end + end + end +end diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb index d2482ac434b..8127a7927f4 100644 --- a/spec/serializers/pipeline_entity_spec.rb +++ b/spec/serializers/pipeline_entity_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe PipelineEntity do - let(:user) { create(:user) } + set(:user) { create(:user) } let(:request) { double('request') } before do @@ -19,7 +19,11 @@ describe PipelineEntity do let(:pipeline) { create(:ci_empty_pipeline) } it 'contains required fields' do +<<<<<<< HEAD expect(subject).to include :id, :user, :path, :coverage +======= + expect(subject).to include :id, :user, :path, :coverage, :source +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(subject).to include :ref, :commit expect(subject).to include :updated_at, :created_at end @@ -28,15 +32,13 @@ describe PipelineEntity do expect(subject).to include :details expect(subject[:details]) .to include :duration, :finished_at - expect(subject[:details]) - .to include :stages, :artifacts, :manual_actions expect(subject[:details][:status]).to include :icon, :favicon, :text, :label end it 'contains flags' do expect(subject).to include :flags expect(subject[:flags]) - .to include :latest, :triggered, :stuck, + .to include :latest, :stuck, :yaml_errors, :retryable, :cancelable end end @@ -55,20 +57,12 @@ describe PipelineEntity do context 'user has ability to retry pipeline' do before { project.team << [user, :developer] } - it 'retryable flag is true' do - expect(subject[:flags][:retryable]).to eq true - end - it 'contains retry path' do expect(subject[:retry_path]).to be_present end end context 'user does not have ability to retry pipeline' do - it 'retryable flag is false' do - expect(subject[:flags][:retryable]).to eq false - end - it 'does not contain retry path' do expect(subject).not_to have_key(:retry_path) end @@ -87,11 +81,7 @@ describe PipelineEntity do end context 'user has ability to cancel pipeline' do - before { project.team << [user, :developer] } - - it 'cancelable flag is true' do - expect(subject[:flags][:cancelable]).to eq true - end + before { project.add_developer(user) } it 'contains cancel path' do expect(subject[:cancel_path]).to be_present @@ -99,42 +89,12 @@ describe PipelineEntity do end context 'user does not have ability to cancel pipeline' do - it 'cancelable flag is false' do - expect(subject[:flags][:cancelable]).to eq false - end - it 'does not contain cancel path' do expect(subject).not_to have_key(:cancel_path) end end end - context 'when pipeline has YAML errors' do - let(:pipeline) do - create(:ci_pipeline, config: { rspec: { invalid: :value } }) - end - - it 'contains flag that indicates there are errors' do - expect(subject[:flags][:yaml_errors]).to be true - end - - it 'contains information about error' do - expect(subject[:yaml_errors]).to be_present - end - end - - context 'when pipeline does not have YAML errors' do - let(:pipeline) { create(:ci_empty_pipeline) } - - it 'contains flag that indicates there are no errors' do - expect(subject[:flags][:yaml_errors]).to be false - end - - it 'does not contain field that normally holds an error' do - expect(subject).not_to have_key(:yaml_errors) - end - end - context 'when pipeline ref is empty' do let(:pipeline) { create(:ci_empty_pipeline) } diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index f2426db6d81..da9ebaa7f77 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -113,7 +113,11 @@ describe PipelineSerializer do it "verifies number of queries" do recorded = ActiveRecord::QueryRecorder.new { subject } +<<<<<<< HEAD expect(recorded.count).to be_within(1).of(58) +======= + expect(recorded.count).to be_within(1).of(60) +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(recorded.cached_count).to eq(0) end diff --git a/spec/serializers/runner_entity_spec.rb b/spec/serializers/runner_entity_spec.rb new file mode 100644 index 00000000000..4f25a8dcfa0 --- /dev/null +++ b/spec/serializers/runner_entity_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe RunnerEntity do + let(:runner) { create(:ci_runner, :specific) } + let(:entity) { described_class.new(runner, request: request, current_user: user) } + let(:request) { double('request') } + let(:project) { create(:empty_project) } + let(:user) { create(:admin) } + + before do + allow(request).to receive(:current_user).and_return(user) + allow(request).to receive(:project).and_return(project) + end + + describe '#as_json' do + subject { entity.as_json } + + it 'contains required fields' do + expect(subject).to include(:id, :description) + expect(subject).to include(:edit_path) + end + end +end diff --git a/spec/serializers/user_entity_spec.rb b/spec/serializers/user_entity_spec.rb index c5d11cbcf5e..cd778e49107 100644 --- a/spec/serializers/user_entity_spec.rb +++ b/spec/serializers/user_entity_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe UserEntity do + include Gitlab::Routing + let(:entity) { described_class.new(user) } let(:user) { create(:user) } subject { entity.as_json } @@ -20,4 +22,8 @@ describe UserEntity do it 'does not expose 2FA OTPs' do expect(subject).not_to include(/otp/) end + + it 'exposes user path' do + expect(subject[:path]).to eq user_path(user) + end end diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb index a8555f5b4a0..effa4633d13 100644 --- a/spec/services/boards/create_service_spec.rb +++ b/spec/services/boards/create_service_spec.rb @@ -14,8 +14,9 @@ describe Boards::CreateService, services: true do it 'creates the default lists' do board = service.execute - expect(board.lists.size).to eq 1 - expect(board.lists.first).to be_closed + expect(board.lists.size).to eq 2 + expect(board.lists.first).to be_backlog + expect(board.lists.last).to be_closed end end diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb index c982031c791..a1e220c2322 100644 --- a/spec/services/boards/issues/list_service_spec.rb +++ b/spec/services/boards/issues/list_service_spec.rb @@ -13,6 +13,7 @@ describe Boards::Issues::ListService, services: true do 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!(:closed) { create(:closed_list, board: board) } @@ -53,12 +54,20 @@ describe Boards::Issues::ListService, services: true do expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1] end + 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 Closed' do params = { board_id: board.id, id: closed.id } issues = described_class.new(project, user, params).execute - expect(issues).to eq [closed_issue4, closed_issue2, closed_issue5, closed_issue3, closed_issue1] + 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 diff --git a/spec/services/boards/lists/list_service_spec.rb b/spec/services/boards/lists/list_service_spec.rb index ab9fb1bc914..68140759600 100644 --- a/spec/services/boards/lists/list_service_spec.rb +++ b/spec/services/boards/lists/list_service_spec.rb @@ -1,16 +1,33 @@ require 'spec_helper' describe Boards::Lists::ListService, services: true do + let(:project) { create(:empty_project) } + let(:board) { create(:board, project: project) } + let(:label) { create(:label, project: project) } + let!(:list) { create(:list, board: board, label: label) } + let(:service) { described_class.new(project, double) } + 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) + context 'when the board has a backlog list' do + let!(:backlog_list) { create(:backlog_list, board: board) } + + it 'does not create a backlog list' do + expect { service.execute(board) }.not_to change(board.lists, :count) + end + + it "returns board's lists" do + expect(service.execute(board)).to eq [backlog_list, list, board.closed_list] + end + end - service = described_class.new(project, double) + context 'when the board does not have a backlog list' do + it 'creates a backlog list' do + expect { service.execute(board) }.to change(board.lists, :count).by(1) + end - expect(service.execute(board)).to eq [list, board.closed_list] + it "returns board's lists" do + expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list] + end end end end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index fa5014cee07..e9c2b865b47 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Ci::CreatePipelineService, services: true do +describe Ci::CreatePipelineService, :services do let(:project) { create(:project, :repository) } let(:user) { create(:admin) } @@ -9,13 +9,13 @@ describe Ci::CreatePipelineService, services: true do end describe '#execute' do - def execute_service(after: project.commit.id, message: 'Message', ref: 'refs/heads/master') + def execute_service(source: :push, after: project.commit.id, message: 'Message', ref: 'refs/heads/master') params = { ref: ref, before: '00000000', after: after, commits: [{ message: message }] } - described_class.new(project, user, params).execute + described_class.new(project, user, params).execute(source) end context 'valid params' do @@ -27,12 +27,64 @@ describe Ci::CreatePipelineService, services: true do ) end - it { expect(pipeline).to be_kind_of(Ci::Pipeline) } - it { expect(pipeline).to be_valid } - it { expect(pipeline).to eq(project.pipelines.last) } - it { expect(pipeline).to have_attributes(user: user) } - it { expect(pipeline).to have_attributes(status: 'pending') } - it { expect(pipeline.builds.first).to be_kind_of(Ci::Build) } + it 'creates a pipeline' do + expect(pipeline).to be_kind_of(Ci::Pipeline) + expect(pipeline).to be_valid + expect(pipeline).to be_persisted + expect(pipeline).to be_push + expect(pipeline).to eq(project.pipelines.last) + expect(pipeline).to have_attributes(user: user) + expect(pipeline).to have_attributes(status: 'pending') + expect(pipeline.builds.first).to be_kind_of(Ci::Build) + end + + context 'when merge requests already exist for this source branch' do + it 'updates head pipeline of each merge request' do + merge_request_1 = create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project) + merge_request_2 = create(:merge_request, source_branch: 'master', target_branch: "branch_2", source_project: project) + + head_pipeline = pipeline + + expect(merge_request_1.reload.head_pipeline).to eq(head_pipeline) + expect(merge_request_2.reload.head_pipeline).to eq(head_pipeline) + end + + context 'when there is no pipeline for source branch' do + it "does not update merge request head pipeline" do + merge_request = create(:merge_request, source_branch: 'other_branch', target_branch: "branch_1", source_project: project) + + head_pipeline = pipeline + + expect(merge_request.reload.head_pipeline).not_to eq(head_pipeline) + end + end + + context 'when merge request target project is different from source project' do + let!(:target_project) { create(:project) } + let!(:forked_project_link) { create(:forked_project_link, forked_to_project: project, forked_from_project: target_project) } + + it 'updates head pipeline for merge request' do + merge_request = + create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project, target_project: target_project) + + head_pipeline = pipeline + + expect(merge_request.reload.head_pipeline).to eq(head_pipeline) + end + end + + context 'when the pipeline is not the latest for the branch' do + it 'does not update merge request head pipeline' do + merge_request = create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project) + + allow_any_instance_of(Ci::Pipeline).to receive(:latest?).and_return(false) + + pipeline + + expect(merge_request.reload.head_pipeline).to be_nil + end + end + end context 'auto-cancel enabled' do before do @@ -245,5 +297,20 @@ describe Ci::CreatePipelineService, services: true do expect(Environment.find_by(name: "review/master")).not_to be_nil end end + + context 'when environment with invalid name' do + before do + config = YAML.dump(deploy: { environment: { name: 'name,with,commas' }, script: 'ls' }) + stub_ci_pipeline_yaml_file(config) + end + + it 'does not create an environment' do + expect do + result = execute_service + + expect(result).to be_persisted + end.not_to change { Environment.count } + 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 5a20102872a..f2956262f4b 100644 --- a/spec/services/ci/create_trigger_request_service_spec.rb +++ b/spec/services/ci/create_trigger_request_service_spec.rb @@ -16,6 +16,7 @@ describe Ci::CreateTriggerRequestService, services: true do context 'without owner' do it { expect(subject).to be_kind_of(Ci::TriggerRequest) } it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) } + it { expect(subject.pipeline).to be_trigger } it { expect(subject.builds.first).to be_kind_of(Ci::Build) } end @@ -25,6 +26,7 @@ describe Ci::CreateTriggerRequestService, services: true do it { expect(subject).to be_kind_of(Ci::TriggerRequest) } it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) } + it { expect(subject.pipeline).to be_trigger } it { expect(subject.pipeline.user).to eq(owner) } it { expect(subject.builds.first).to be_kind_of(Ci::Build) } it { expect(subject.builds.first.user).to eq(owner) } diff --git a/spec/services/ci/play_build_service_spec.rb b/spec/services/ci/play_build_service_spec.rb index d6f9fa42045..ea211de1f82 100644 --- a/spec/services/ci/play_build_service_spec.rb +++ b/spec/services/ci/play_build_service_spec.rb @@ -13,8 +13,11 @@ describe Ci::PlayBuildService, '#execute', :services do context 'when project does not have repository yet' do let(:project) { create(:empty_project) } - it 'allows user with master role to play build' do - project.add_master(user) + it 'allows user to play build if protected branch rules are met' do + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) service.execute(build) @@ -45,7 +48,10 @@ describe Ci::PlayBuildService, '#execute', :services do let(:build) { create(:ci_build, :manual, pipeline: pipeline) } before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'enqueues the build' do @@ -64,7 +70,10 @@ describe Ci::PlayBuildService, '#execute', :services do let(:build) { create(:ci_build, when: :manual, pipeline: pipeline) } before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'duplicates the build' do diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index fc5de5d069a..1557cb3c938 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -333,10 +333,11 @@ describe Ci::ProcessPipelineService, '#execute', :services do context 'when pipeline is promoted sequentially up to the end' do before do - # We are using create(:empty_project), and users has to be master in - # order to execute manual action when repository does not exist. + # Users need ability to merge into a branch in order to trigger + # protected manual actions. # - project.add_master(user) + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end it 'properly processes entire pipeline' do diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index 7254e6b357a..ef9927c5969 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -25,12 +25,24 @@ describe Ci::RetryBuildService, :services do user_id auto_canceled_by_id retried].freeze shared_examples 'build duplication' do + let(:stage) do + # TODO, we still do not have factory for new stages, we will need to + # switch existing factory to persist stages, instead of using LegacyStage + # + Ci::Stage.create!(project: project, pipeline: pipeline, name: 'test') + end + let(:build) do create(:ci_build, :failed, :artifacts_expired, :erased, :queued, :coverage, :tags, :allowed_to_fail, :on_tag, - :teardown_environment, :triggered, :trace, - description: 'some build', pipeline: pipeline, - auto_canceled_by: create(:ci_empty_pipeline)) + :triggered, :trace, :teardown_environment, + description: 'my-job', stage: 'test', pipeline: pipeline, + auto_canceled_by: create(:ci_empty_pipeline)) do |build| + ## + # TODO, workaround for FactoryGirl limitation when having both + # stage (text) and stage_id (integer) columns in the table. + build.stage_id = stage.id + end end describe 'clone accessors' do diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb index d941d56c0d8..3e860203063 100644 --- a/spec/services/ci/retry_pipeline_service_spec.rb +++ b/spec/services/ci/retry_pipeline_service_spec.rb @@ -6,9 +6,12 @@ describe Ci::RetryPipelineService, '#execute', :services do let(:pipeline) { create(:ci_pipeline, project: project) } let(:service) { described_class.new(project, user) } - context 'when user has ability to modify pipeline' do + context 'when user has full ability to modify pipeline' do before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: pipeline.ref, project: project) end context 'when there are already retried jobs present' do diff --git a/spec/services/cohorts_service_spec.rb b/spec/services/cohorts_service_spec.rb index 1e99442fdcb..77595d7ba2d 100644 --- a/spec/services/cohorts_service_spec.rb +++ b/spec/services/cohorts_service_spec.rb @@ -89,7 +89,7 @@ describe CohortsService do activity_months: [{ total: 2, percentage: 100 }], total: 2, inactive: 1 - }, + } ] expect(described_class.new.execute).to eq(months_included: 12, diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb index a883705bd45..5398b5c3f7e 100644 --- a/spec/services/create_deployment_service_spec.rb +++ b/spec/services/create_deployment_service_spec.rb @@ -1,156 +1,117 @@ require 'spec_helper' describe CreateDeploymentService, services: true do - let(:project) { create(:empty_project) } let(:user) { create(:user) } + let(:options) { nil } + + let(:job) do + create(:ci_build, + ref: 'master', + tag: false, + environment: 'production', + options: { environment: options }) + end - let(:service) { described_class.new(project, user, params) } + let(:project) { job.project } - describe '#execute' do - let(:options) { nil } - let(:params) do - { - environment: 'production', - ref: 'master', - tag: false, - sha: '97de212e80737a608d939f648d959671fb0a0142', - options: options - } - end + let!(:environment) do + create(:environment, project: project, name: 'production') + end - subject { service.execute } + let(:service) { described_class.new(job) } - context 'when no environments exist' do - it 'does create a new environment' do - expect { subject }.to change { Environment.count }.by(1) - end + describe '#execute' do + subject { service.execute } - it 'does create a deployment' do + context 'when environment exists' do + it 'creates a deployment' do expect(subject).to be_persisted end end - context 'when environment exist' do - let!(:environment) { create(:environment, project: project, name: 'production') } - - it 'does not create a new environment' do - expect { subject }.not_to change { Environment.count } - end + context 'when environment does not exist' do + let(:environment) {} - it 'does create a deployment' do - expect(subject).to be_persisted + it 'does not create a deployment' do + expect do + expect(subject).to be_nil + end.not_to change { Deployment.count } end + end - context 'and start action is defined' do - let(:options) { { action: 'start' } } + context 'when start action is defined' do + let(:options) { { action: 'start' } } - context 'and environment is stopped' do - before do - environment.stop - end + context 'and environment is stopped' do + before do + environment.stop + end - it 'makes environment available' do - subject + it 'makes environment available' do + subject - expect(environment.reload).to be_available - end + expect(environment.reload).to be_available + end - it 'does create a deployment' do - expect(subject).to be_persisted - end + it 'creates a deployment' do + expect(subject).to be_persisted end end + end - context 'and stop action is defined' do - let(:options) { { action: 'stop' } } - - context 'and environment is available' do - before do - environment.start - end - - it 'makes environment stopped' do - subject - - expect(environment.reload).to be_stopped - end + context 'when stop action is defined' do + let(:options) { { action: 'stop' } } - it 'does not create a deployment' do - expect(subject).to be_nil - end + context 'and environment is available' do + before do + environment.start end - end - end - context 'for environment with invalid name' do - let(:params) do - { - environment: 'name,with,commas', - ref: 'master', - tag: false, - sha: '97de212e80737a608d939f648d959671fb0a0142' - } - end + it 'makes environment stopped' do + subject - it 'does not create a new environment' do - expect { subject }.not_to change { Environment.count } - end + expect(environment.reload).to be_stopped + end - it 'does not create a deployment' do - expect(subject).to be_nil + it 'does not create a deployment' do + expect(subject).to be_nil + end end end context 'when variables are used' do - let(:params) do - { - environment: 'review-apps/$CI_COMMIT_REF_NAME', - ref: 'master', - tag: false, - sha: '97de212e80737a608d939f648d959671fb0a0142', - options: { - name: 'review-apps/$CI_COMMIT_REF_NAME', - url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com' - }, - variables: [ - { key: 'CI_COMMIT_REF_NAME', value: 'feature-review-apps' } - ] - } + let(:options) do + { name: 'review-apps/$CI_COMMIT_REF_NAME', + url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com' } 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') + before do + environment.update(name: 'review-apps/master') + job.update(environment: 'review-apps/$CI_COMMIT_REF_NAME') end - it 'does create a new deployment' do + it 'creates a new deployment' do expect(subject).to be_persisted end - context 'and environment exist' do - let!(:environment) { create(:environment, project: project, name: 'review-apps/feature-review-apps') } - - it 'does not create a new environment' do - expect { subject }.not_to change { Environment.count } - end - - it 'updates external url' do - subject + it 'does not create a new environment' do + expect { subject }.not_to change { Environment.count } + end - 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 'updates external url' do + subject - it 'does create a new deployment' do - expect(subject).to be_persisted - end + expect(subject.environment.name).to eq('review-apps/master') + expect(subject.environment.external_url).to eq('http://master.review-apps.gitlab.com') end end context 'when project was removed' do - let(:project) { nil } + let(:environment) {} + + before do + job.update(project: nil) + end it 'does not create deployment or environment' do expect { subject }.not_to raise_error @@ -162,34 +123,26 @@ describe CreateDeploymentService, services: true do end describe 'processing of builds' do - let(:environment) { nil } - - shared_examples 'does not create environment and deployment' do - it 'does not create a new environment' do - expect { subject }.not_to change { Environment.count } - end - + shared_examples 'does not create deployment' do it 'does not create a new deployment' do expect { subject }.not_to change { Deployment.count } end it 'does not call a service' do expect_any_instance_of(described_class).not_to receive(:execute) + subject end end - shared_examples 'does create environment and deployment' do - it 'does create a new environment' do - expect { subject }.to change { Environment.count }.by(1) - end - - it 'does create a new deployment' do + shared_examples 'creates deployment' do + it 'creates a new deployment' do expect { subject }.to change { Deployment.count }.by(1) end - it 'does call a service' do + it 'calls a service' do expect_any_instance_of(described_class).to receive(:execute) + subject end @@ -199,7 +152,7 @@ describe CreateDeploymentService, services: true do expect(Deployment.last.deployable).to eq(deployable) end - it 'create environment has URL set' do + it 'updates environment URL' do subject expect(Deployment.last.environment.external_url).not_to be_nil @@ -207,41 +160,39 @@ describe CreateDeploymentService, services: true do end context 'without environment specified' do - let(:build) { create(:ci_build, project: project) } + let(:job) { create(:ci_build) } - it_behaves_like 'does not create environment and deployment' do - subject { build.success } + it_behaves_like 'does not create deployment' do + subject { job.success } end end context 'when environment is specified' do - let(:pipeline) { create(:ci_pipeline, project: project) } - let(:build) { create(:ci_build, pipeline: pipeline, environment: 'production', options: options) } + let(:deployable) { job } + 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 - let(:deployable) { build } - - subject { build.success } + context 'when job succeeds' do + it_behaves_like 'creates deployment' do + subject { job.success } end end - context 'when build fails' do - it_behaves_like 'does not create environment and deployment' do - subject { build.drop } + context 'when job fails' do + it_behaves_like 'does not create deployment' do + subject { job.drop } end end - context 'when build is retried' do - it_behaves_like 'does create environment and deployment' do + context 'when job is retried' do + it_behaves_like 'creates deployment' do before do project.add_developer(user) end - let(:deployable) { Ci::Build.retry(build, user) } + let(:deployable) { Ci::Build.retry(job, user) } subject { deployable.success } end @@ -250,15 +201,6 @@ describe CreateDeploymentService, services: true do 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 @@ -273,8 +215,8 @@ describe CreateDeploymentService, services: true do 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) + job.update(environment: 'staging') + service = described_class.new(job) service.execute expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil @@ -298,7 +240,7 @@ describe CreateDeploymentService, services: true do expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(time) # Current deploy - service = described_class.new(project, user, params) + service = described_class.new(job) Timecop.freeze(time + 12.hours) { service.execute } expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(time) @@ -318,7 +260,7 @@ describe CreateDeploymentService, services: true do expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil # Current deploy - service = described_class.new(project, user, params) + service = described_class.new(job) Timecop.freeze(time + 12.hours) { service.execute } expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil diff --git a/spec/services/delete_merged_branches_service_spec.rb b/spec/services/delete_merged_branches_service_spec.rb index 7b921f606f8..cae74df9c90 100644 --- a/spec/services/delete_merged_branches_service_spec.rb +++ b/spec/services/delete_merged_branches_service_spec.rb @@ -6,33 +6,22 @@ describe DeleteMergedBranchesService, services: true do let(:project) { create(:project, :repository) } context '#execute' do - context 'unprotected branches' do - before do - service.execute - end + it 'deletes a branch that was merged' do + service.execute - it 'deletes a branch that was merged' do - expect(project.repository.branch_names).not_to include('improve/awesome') - end + expect(project.repository.branch_names).not_to include('improve/awesome') + end - it 'keeps branch that is unmerged' do - expect(project.repository.branch_names).to include('feature') - end + it 'keeps branch that is unmerged' do + service.execute - it 'keeps "master"' do - expect(project.repository.branch_names).to include('master') - end + expect(project.repository.branch_names).to include('feature') end - context 'protected branches' do - before do - create(:protected_branch, name: 'improve/awesome', project: project) - service.execute - end + it 'keeps "master"' do + service.execute - it 'keeps protected branch' do - expect(project.repository.branch_names).to include('improve/awesome') - end + expect(project.repository.branch_names).to include('master') end context 'user without rights' do diff --git a/spec/services/notes/diff_position_update_service_spec.rb b/spec/services/discussions/update_diff_position_service_spec.rb index d73ae51fbc3..177e32e13bd 100644 --- a/spec/services/notes/diff_position_update_service_spec.rb +++ b/spec/services/discussions/update_diff_position_service_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' -describe Notes::DiffPositionUpdateService, services: true do +describe Discussions::UpdateDiffPositionService, services: true do let(:project) { create(:project, :repository) } + let(:current_user) { project.owner } let(:create_commit) { project.commit("913c66a37b4a45b9769037c55c2d238bd0942d2e") } let(:modify_commit) { project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e") } let(:edit_commit) { project.commit("570e7b2abdd848b95f2f578043fc23bd6f6fd24d") } @@ -25,7 +26,7 @@ describe Notes::DiffPositionUpdateService, services: true do subject do described_class.new( project, - nil, + current_user, old_diff_refs: old_diff_refs, new_diff_refs: new_diff_refs, paths: [path] @@ -137,7 +138,7 @@ describe Notes::DiffPositionUpdateService, services: true do # .. .. describe "#execute" do - let(:note) { create(:diff_note_on_merge_request, project: project, position: old_position) } + let(:discussion) { create(:diff_note_on_merge_request, project: project, position: old_position).to_discussion } let(:old_position) do Gitlab::Diff::Position.new( @@ -153,11 +154,11 @@ describe Notes::DiffPositionUpdateService, services: true do let(:line) { 16 } it "updates the position" do - subject.execute(note) + subject.execute(discussion) - expect(note.original_position).to eq(old_position) - expect(note.position).not_to eq(old_position) - expect(note.position.new_line).to eq(22) + expect(discussion.original_position).to eq(old_position) + expect(discussion.position).not_to eq(old_position) + expect(discussion.position.new_line).to eq(22) end end @@ -165,10 +166,27 @@ describe Notes::DiffPositionUpdateService, services: true do let(:line) { 9 } it "doesn't update the position" do - subject.execute(note) + subject.execute(discussion) - expect(note.original_position).to eq(old_position) - expect(note.position).to eq(old_position) + expect(discussion.original_position).to eq(old_position) + expect(discussion.position).to eq(old_position) + end + + it 'sets the change position' do + subject.execute(discussion) + + change_position = discussion.change_position + expect(change_position.start_sha).to eq(old_diff_refs.head_sha) + expect(change_position.head_sha).to eq(new_diff_refs.head_sha) + expect(change_position.old_line).to eq(9) + expect(change_position.new_line).to be_nil + end + + it 'creates a system discussion' do + expect(SystemNoteService).to receive(:diff_discussion_outdated).with( + discussion, project, current_user, instance_of(Gitlab::Diff::Position)) + + subject.execute(discussion) end end end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 0477cac6677..bcd1fb64ab9 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -131,6 +131,19 @@ describe GitPushService, services: true do end end + describe "Pipelines" do + subject { execute_service(project, user, @oldrev, @newrev, @ref) } + + before do + stub_ci_pipeline_to_return_yaml_file + end + + it "creates a new pipeline" do + expect{ subject }.to change{ Ci::Pipeline.count } + expect(Ci::Pipeline.last).to be_push + end + end + describe "Push Event" do before do service = execute_service(project, user, @oldrev, @newrev, @ref ) @@ -436,6 +449,7 @@ describe GitPushService, services: true do author_name: commit_author.name, author_email: commit_author.email }) + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) allow(project.repository).to receive_messages(commits_between: [closing_commit]) end @@ -584,7 +598,7 @@ describe GitPushService, services: true do commit = double(:commit) diff = double(:diff, new_path: 'README.md') - expect(commit).to receive(:raw_diffs).with(deltas_only: true). + expect(commit).to receive(:raw_deltas). and_return([diff]) service.push_commits = [commit] @@ -622,12 +636,21 @@ describe GitPushService, services: true do it 'only schedules a limited number of commits' do allow(service).to receive(:push_commits). - and_return(Array.new(1000, double(:commit, to_hash: {}))) + and_return(Array.new(1000, double(:commit, to_hash: {}, matches_cross_reference_regex?: true))) expect(ProcessCommitWorker).to receive(:perform_async).exactly(100).times service.process_commit_messages end + + it "skips commits which don't include cross-references" do + allow(service).to receive(:push_commits). + and_return([double(:commit, to_hash: {}, matches_cross_reference_regex?: false)]) + + expect(ProcessCommitWorker).not_to receive(:perform_async) + + service.process_commit_messages + end end def execute_service(project, user, oldrev, newrev, ref) diff --git a/spec/services/git_tag_push_service_spec.rb b/spec/services/git_tag_push_service_spec.rb index b73beb3f6fc..1fdcb420a8b 100644 --- a/spec/services/git_tag_push_service_spec.rb +++ b/spec/services/git_tag_push_service_spec.rb @@ -30,6 +30,20 @@ describe GitTagPushService, services: true do end end + describe "Pipelines" do + subject { service.execute } + + before do + stub_ci_pipeline_to_return_yaml_file + project.team << [user, :developer] + end + + it "creates a new pipeline" do + expect{ subject }.to change{ Ci::Pipeline.count } + expect(Ci::Pipeline.last).to be_push + end + end + describe "Git Tag Push Data" do subject { @push_data } let(:tag) { project.repository.find_tag(tag_name) } diff --git a/spec/services/gravatar_service_spec.rb b/spec/services/gravatar_service_spec.rb new file mode 100644 index 00000000000..8c4ad8c7a3e --- /dev/null +++ b/spec/services/gravatar_service_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe GravatarService, service: true do + describe '#execute' do + let(:url) { 'http://example.com/avatar?hash=%{hash}&size=%{size}&email=%{email}&username=%{username}' } + + before do + allow(Gitlab.config.gravatar).to receive(:plain_url).and_return(url) + end + + it 'replaces the placeholders' do + avatar_url = described_class.new.execute('user@example.com', 100, 2, username: 'user') + + expect(avatar_url).to include("hash=#{Digest::MD5.hexdigest('user@example.com')}") + expect(avatar_url).to include("size=200") + expect(avatar_url).to include("email=user%40example.com") + expect(avatar_url).to include("username=user") + end + end +end diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 8fd56214752..eb9b1670c71 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -72,7 +72,7 @@ describe Issuable::BulkUpdateService, services: true do end context "when the new assignee ID is #{IssuableFinder::NONE}" do - it "unassigns the issues" do + it 'unassigns the issues' do expect { bulk_update(merge_request, assignee_id: IssuableFinder::NONE) } .to change { merge_request.reload.assignee }.to(nil) end @@ -163,7 +163,7 @@ describe Issuable::BulkUpdateService, services: true do { label_ids: labels.map(&:id), add_label_ids: add_labels.map(&:id), - remove_label_ids: remove_labels.map(&:id), + remove_label_ids: remove_labels.map(&:id) } end diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index 55d635235b0..bed25fe7ccf 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -136,7 +136,7 @@ describe Issues::BuildService, services: true do user, title: 'Issue #1', description: 'Issue description', - milestone_id: milestone.id, + milestone_id: milestone.id ).execute expect(issue.title).to eq('Issue #1') diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 51840531711..be0e829880e 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -41,6 +41,12 @@ describe Issues::CloseService, services: true do service.execute(issue) end + + it 'invalidates counter cache for assignees' do + expect_any_instance_of(User).to receive(:invalidate_issue_cache_counts) + + service.execute(issue) + end end describe '#close_issue' do @@ -51,8 +57,10 @@ describe Issues::CloseService, services: true do end end - it { expect(issue).to be_valid } - it { expect(issue).to be_closed } + it 'closes the issue' do + expect(issue).to be_valid + expect(issue).to be_closed + end it 'sends email to user2 about assign of new issue' do email = ActionMailer::Base.deliveries.last @@ -96,9 +104,11 @@ describe Issues::CloseService, services: true do described_class.new(project, user).close_issue(issue) end - it { expect(issue).to be_valid } - it { expect(issue).to be_opened } - it { expect(todo.reload).to be_pending } + it 'closes the issue' do + expect(issue).to be_valid + expect(issue).to be_opened + expect(todo.reload).to be_pending + end end end end diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb index 93a8270fd16..391ecad303a 100644 --- a/spec/services/issues/reopen_service_spec.rb +++ b/spec/services/issues/reopen_service_spec.rb @@ -27,6 +27,13 @@ describe Issues::ReopenService, services: true do project.team << [user, :master] end + it 'invalidates counter cache for assignees' do + issue.assignees << user + expect_any_instance_of(User).to receive(:invalidate_issue_cache_counts) + + described_class.new(project, user).execute(issue) + 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) diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb index c3b4c2176ee..86f218dec12 100644 --- a/spec/services/issues/resolve_discussions_spec.rb +++ b/spec/services/issues/resolve_discussions_spec.rb @@ -77,7 +77,7 @@ describe Issues::ResolveDiscussions, services: true do _second_discussion = Discussion.new([create(:diff_note_on_merge_request, :resolved, noteable: merge_request, project: merge_request.target_project, - line_number: 15, + line_number: 15 )]) service = DummyService.new( project, diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb index 0670ac2faa2..5ce8e17976b 100644 --- a/spec/services/members/create_service_spec.rb +++ b/spec/services/members/create_service_spec.rb @@ -11,7 +11,7 @@ describe Members::CreateService, services: true do params = { user_ids: project_user.id.to_s, access_level: Gitlab::Access::GUEST } result = described_class.new(project, user, params).execute - expect(result).to be_truthy + expect(result[:status]).to eq(:success) expect(project.users).to include project_user end @@ -19,7 +19,19 @@ describe Members::CreateService, services: true do params = { user_ids: '', access_level: Gitlab::Access::GUEST } result = described_class.new(project, user, params).execute - expect(result).to be_falsey + expect(result[:status]).to eq(:error) + expect(result[:message]).to be_present + expect(project.users).not_to include project_user + end + + it 'limits the number of users to 100' do + user_ids = 1.upto(101).to_a.join(',') + params = { user_ids: user_ids, access_level: Gitlab::Access::GUEST } + + result = described_class.new(project, user, params).execute + + expect(result[:status]).to eq(:error) + expect(result[:message]).to be_present expect(project.users).not_to include project_user end end diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index d55a7657c0e..154f30aac3b 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -15,6 +15,8 @@ describe MergeRequests::CloseService, services: true do end describe '#execute' do + it_behaves_like 'cache counters invalidator' + context 'valid params' do let(:service) { described_class.new(project, user, {}) } diff --git a/spec/services/merge_requests/conflicts/resolve_service_spec.rb b/spec/services/merge_requests/conflicts/resolve_service_spec.rb index 19e8d5cc5f1..3d1660965b1 100644 --- a/spec/services/merge_requests/conflicts/resolve_service_spec.rb +++ b/spec/services/merge_requests/conflicts/resolve_service_spec.rb @@ -26,6 +26,13 @@ describe MergeRequests::Conflicts::ResolveService do describe '#execute' do let(:service) { described_class.new(merge_request) } +<<<<<<< HEAD:spec/services/merge_requests/conflicts/resolve_service_spec.rb +======= + def blob_content(project, ref, path) + project.repository.blob_at(ref, path).data + end + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259:spec/services/merge_requests/conflicts/resolve_service_spec.rb context 'with section params' do let(:params) do { @@ -66,6 +73,35 @@ describe MergeRequests::Conflicts::ResolveService do end end + context 'when some files have trailing newlines' do + let!(:source_head) do + branch = 'conflict-resolvable' + path = 'files/ruby/popen.rb' + popen_content = blob_content(project, branch, path) + + project.repository.update_file( + user, + path, + popen_content.chomp("\n"), + message: 'Remove trailing newline from popen.rb', + branch_name: branch + ) + end + + before do + service.execute(user, params) + end + + it 'preserves trailing newlines from our side of the conflicts' do + head_sha = merge_request.source_branch_head.sha + popen_content = blob_content(project, head_sha, 'files/ruby/popen.rb') + regex_content = blob_content(project, head_sha, 'files/ruby/regex.rb') + + expect(popen_content).not_to end_with("\n") + expect(regex_content).to end_with("\n") + 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.create_file( @@ -142,10 +178,13 @@ describe MergeRequests::Conflicts::ResolveService do end it 'sets the content to the content given' do - blob = merge_request.source_project.repository.blob_at(merge_request.source_branch_head.sha, - 'files/ruby/popen.rb') + blob = blob_content( + merge_request.source_project, + merge_request.source_branch_head.sha, + 'files/ruby/popen.rb' + ) - expect(blob.data).to eq(popen_content) + expect(blob).to eq(popen_content) end end diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 41752f1a01a..2963f62cc7d 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -27,10 +27,12 @@ describe MergeRequests::CreateService, services: true do @merge_request = service.execute end - it { expect(@merge_request).to be_valid } - it { expect(@merge_request.title).to eq('Awesome merge_request') } - it { expect(@merge_request.assignee).to be_nil } - it { expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1') } + it 'creates an MR' do + expect(@merge_request).to be_valid + expect(@merge_request.title).to eq('Awesome merge_request') + expect(@merge_request.assignee).to be_nil + expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1') + end it 'executes hooks with default action' do expect(service).to have_received(:execute_hooks).with(@merge_request) @@ -73,6 +75,37 @@ describe MergeRequests::CreateService, services: true do expect(Todo.where(attributes).count).to eq 1 end end + + context 'when head pipelines already exist for merge request source branch' do + let(:sha) { project.commit(opts[:source_branch]).id } + let!(:pipeline_1) { create(:ci_pipeline, project: project, ref: opts[:source_branch], project_id: project.id, sha: sha) } + let!(:pipeline_2) { create(:ci_pipeline, project: project, ref: opts[:source_branch], project_id: project.id, sha: sha) } + let!(:pipeline_3) { create(:ci_pipeline, project: project, ref: "other_branch", project_id: project.id) } + + before do + project.merge_requests. + where(source_branch: opts[:source_branch], target_branch: opts[:target_branch]). + destroy_all + end + + it 'sets head pipeline' do + merge_request = service.execute + + expect(merge_request.head_pipeline).to eq(pipeline_2) + expect(merge_request).to be_persisted + end + + context 'when merge request head commit sha does not match pipeline sha' do + it 'sets the head pipeline correctly' do + pipeline_2.update(sha: 1234) + + merge_request = service.execute + + expect(merge_request.head_pipeline).to eq(pipeline_1) + expect(merge_request).to be_persisted + end + end + end end it_behaves_like 'new issuable record that supports slash commands' do diff --git a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb b/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb index 769b3193275..f17db70faf6 100644 --- a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb +++ b/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb @@ -79,7 +79,8 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do 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') + sha: merge_request_head, status: 'success', + head_pipeline_of: mr_merge_if_green_enabled) end it "merges all merge requests with merge when the pipeline succeeds enabled" do @@ -121,7 +122,8 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do let(:conflict_pipeline) do create(:ci_pipeline, project: project, ref: mr_conflict.source_branch, - sha: mr_conflict.diff_head_sha, status: 'success') + sha: mr_conflict.diff_head_sha, status: 'success', + head_pipeline_of: mr_conflict) end it 'does not merge the merge request' do diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb new file mode 100644 index 00000000000..a20b32eda5f --- /dev/null +++ b/spec/services/merge_requests/post_merge_service_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe MergeRequests::PostMergeService, services: true do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request, assignee: user) } + let(:project) { merge_request.project } + + before do + project.team << [user, :master] + end + + describe '#execute' do + it_behaves_like 'cache counters invalidator' + end +end diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 03215a4624a..1f109eab268 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -348,7 +348,7 @@ describe MergeRequests::RefreshService, services: true do title: 'fixup! Fix issue', work_in_progress?: true, to_reference: 'ccccccc' - ), + ) ]) refresh_service.execute(@oldrev, @newrev, 'refs/heads/wip') reload_mrs diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb index a99d4eac9bd..b6d4db2f922 100644 --- a/spec/services/merge_requests/reopen_service_spec.rb +++ b/spec/services/merge_requests/reopen_service_spec.rb @@ -14,6 +14,8 @@ describe MergeRequests::ReopenService, services: true do end describe '#execute' do + it_behaves_like 'cache counters invalidator' + context 'valid params' do let(:service) { described_class.new(project, user, {}) } diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 2bd5c3531cb..d371fc68312 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -59,14 +59,16 @@ describe MergeRequests::UpdateService, services: true do end end - it { expect(@merge_request).to be_valid } - it { expect(@merge_request.title).to eq('New title') } - it { expect(@merge_request.assignee).to eq(user2) } - it { expect(@merge_request).to be_closed } - it { expect(@merge_request.labels.count).to eq(1) } - it { expect(@merge_request.labels.first.title).to eq(label.name) } - it { expect(@merge_request.target_branch).to eq('target') } - it { expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1') } + it 'mathces base expectations' do + expect(@merge_request).to be_valid + expect(@merge_request.title).to eq('New title') + expect(@merge_request.assignee).to eq(user2) + expect(@merge_request).to be_closed + expect(@merge_request.labels.count).to eq(1) + expect(@merge_request.labels.first.title).to eq(label.name) + expect(@merge_request.target_branch).to eq('target') + expect(@merge_request.merge_params['force_remove_source_branch']).to eq('1') + end it 'executes hooks with update action' do expect(service).to have_received(:execute_hooks). @@ -148,9 +150,11 @@ describe MergeRequests::UpdateService, services: true do end end - it { expect(@merge_request).to be_valid } - it { expect(@merge_request.state).to eq('merged') } - it { expect(@merge_request.merge_error).to be_nil } + it 'merges the MR' do + expect(@merge_request).to be_valid + expect(@merge_request.state).to eq('merged') + expect(@merge_request.merge_error).to be_nil + end end context 'with finished pipeline' do @@ -167,17 +171,22 @@ describe MergeRequests::UpdateService, services: true do end end - it { expect(@merge_request).to be_valid } - it { expect(@merge_request.state).to eq('merged') } + it 'merges the MR' do + expect(@merge_request).to be_valid + expect(@merge_request.state).to eq('merged') + end end context 'with active pipeline' do before do service_mock = double - create(:ci_pipeline_with_one_job, + create( + :ci_pipeline_with_one_job, project: project, - ref: merge_request.source_branch, - sha: merge_request.diff_head_sha) + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha, + head_pipeline_of: merge_request + ) expect(MergeRequests::MergeWhenPipelineSucceedsService).to receive(:new).with(project, user). and_return(service_mock) @@ -200,8 +209,10 @@ describe MergeRequests::UpdateService, services: true do end end - it { expect(@merge_request.state).to eq('opened') } - it { expect(@merge_request.merge_error).not_to be_nil } + it 'does not merge the MR' do + expect(@merge_request.state).to eq('opened') + expect(@merge_request.merge_error).not_to be_nil + end end context 'MR can not be merged when note sha != MR sha' do diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 74f96b97909..de3bbc6b6a1 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -350,7 +350,7 @@ describe NotificationService, services: true do create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_participant), create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_mentioned), create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_disabled), - create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_note_author), + create(:note_on_personal_snippet, noteable: snippet, note: 'note', author: @u_note_author) ] end diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 033e6ecd18c..40298dcb723 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -115,7 +115,7 @@ describe Projects::CreateService, '#execute', services: true do stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) opts.merge!( - visibility_level: Gitlab::VisibilityLevel.options['Public'] + visibility_level: Gitlab::VisibilityLevel::PUBLIC ) end @@ -161,15 +161,13 @@ describe Projects::CreateService, '#execute', services: true do end context 'when a bad service template is created' do - before do - create(:service, type: 'DroneCiService', project: nil, template: true, active: true) - end - it 'reports an error in the imported project' do opts[:import_url] = 'http://www.gitlab.com/gitlab-org/gitlab-ce' + create(:service, type: 'DroneCiService', project: nil, template: true, active: true) + project = create_project(user, opts) - expect(project.errors.full_messages_for(:base).first).to match /Unable to save project. Error: Unable to save DroneCiService/ + expect(project.errors.full_messages_for(:base).first).to match(/Unable to save project. Error: Unable to save DroneCiService/) expect(project.services.count).to eq 0 end end diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index f8eb34f2ef4..0df81f3abcb 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe Projects::ForkService, services: true do describe 'fork by user' do before do - @from_namespace = create(:namespace) - @from_user = create(:user, namespace: @from_namespace ) + @from_user = create(:user) + @from_namespace = @from_user.namespace avatar = fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png") @from_project = create(:project, :repository, @@ -13,8 +13,8 @@ describe Projects::ForkService, services: true do star_count: 107, avatar: avatar, description: 'wow such project') - @to_namespace = create(:namespace) - @to_user = create(:user, namespace: @to_namespace) + @to_user = create(:user) + @to_namespace = @to_user.namespace @from_project.add_user(@to_user, :developer) end diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb index 852a4ac852f..44db299812f 100644 --- a/spec/services/projects/import_service_spec.rb +++ b/spec/services/projects/import_service_spec.rb @@ -186,7 +186,7 @@ describe Projects::ImportService, services: true do } ) - allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider]) + stub_omniauth_setting(providers: [provider]) end end end diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index d524c9aff17..cea44746c11 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -6,7 +6,6 @@ describe Projects::ParticipantsService, services: true do let(:project) { create(:empty_project, :public) } let(:group) { create(:group, avatar: fixture_file_upload(Rails.root + 'spec/fixtures/dk.png')) } let(:user) { create(:user) } - let(:base_url) { Settings.send(:build_base_gitlab_url) } let!(:group_member) { create(:group_member, group: group, user: user) } it 'should return an url for the avatar' do @@ -14,7 +13,11 @@ describe Projects::ParticipantsService, services: true do groups = participants.groups expect(groups.size).to eq 1 +<<<<<<< HEAD expect(groups.first[:avatar_url]).to eq "#{base_url}/uploads/system/group/avatar/#{group.id}/dk.png" +======= + expect(groups.first[:avatar_url]).to eq("/uploads/group/avatar/#{group.id}/dk.png") +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end it 'should return an url for the avatar with relative url' do @@ -25,7 +28,11 @@ describe Projects::ParticipantsService, services: true do groups = participants.groups expect(groups.size).to eq 1 +<<<<<<< HEAD expect(groups.first[:avatar_url]).to eq "#{base_url}/gitlab/uploads/system/group/avatar/#{group.id}/dk.png" +======= + expect(groups.first[:avatar_url]).to eq("/gitlab/uploads/group/avatar/#{group.id}/dk.png") +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end end end diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index 29ccce59c53..b957517c715 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -26,6 +26,7 @@ describe Projects::TransferService, services: true do it { expect(@result).to eq false } it { expect(project.namespace).to eq(user.namespace) } + it { expect(project.errors.messages[:new_namespace].first).to eq 'Please select a new namespace for your project.' } end context 'disallow transfering of project with tags' do diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 2112f1cf9ea..5cf989105d0 100644 --- a/spec/services/search_service_spec.rb +++ b/spec/services/search_service_spec.rb @@ -26,6 +26,15 @@ describe SearchService, services: true do expect(project).to eq accessible_project end + + it 'returns the project for guests' do + search_project = create :empty_project + search_project.add_guest(user) + + project = SearchService.new(user, project_id: search_project.id).project + + expect(project).to eq search_project + end end context 'when the project is not accessible' do diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index e5e400ee281..4db491fd5f3 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -384,7 +384,7 @@ describe SlashCommands::InterpretService, services: true do it 'fetches assignee and populates assignee_id if content contains /assign' do _, updates = service.execute(content, issue) - expect(updates[:assignee_ids]).to match_array([developer.id, developer2.id]) + expect(updates[:assignee_ids]).to match_array([developer.id]) end end diff --git a/spec/services/submit_usage_ping_service_spec.rb b/spec/services/submit_usage_ping_service_spec.rb new file mode 100644 index 00000000000..63a1e78f274 --- /dev/null +++ b/spec/services/submit_usage_ping_service_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' + +describe SubmitUsagePingService do + context 'when usage ping is disabled' do + before do + stub_application_setting(usage_ping_enabled: false) + end + + it 'does not run' do + expect(HTTParty).not_to receive(:post) + + result = subject.execute + + expect(result).to eq false + end + end + + context 'when usage ping is enabled' do + before do + stub_application_setting(usage_ping_enabled: true) + end + + it 'sends a POST request' do + response = stub_response(without_conv_index_params) + + subject.execute + + expect(response).to have_been_requested + end + + it 'refreshes usage data statistics before submitting' do + stub_response(without_conv_index_params) + + expect(Gitlab::UsageData).to receive(:to_json) + .with(force_refresh: true) + .and_call_original + + subject.execute + end + + it 'saves conversational development index data from the response' do + stub_response(with_conv_index_params) + + expect { subject.execute } + .to change { ConversationalDevelopmentIndex::Metric.count } + .by(1) + + expect(ConversationalDevelopmentIndex::Metric.last.leader_issues).to eq 10.2 + end + end + + def without_conv_index_params + { + conv_index: {} + } + end + + def with_conv_index_params + { + conv_index: { + leader_issues: 10.2, + instance_issues: 3.2, + + leader_notes: 25.3, + instance_notes: 23.2, + + leader_milestones: 16.2, + instance_milestones: 5.5, + + leader_boards: 5.2, + instance_boards: 3.2, + + leader_merge_requests: 5.2, + instance_merge_requests: 3.2, + + leader_ci_pipelines: 25.1, + instance_ci_pipelines: 21.3, + + leader_environments: 3.3, + instance_environments: 2.2, + + leader_deployments: 41.3, + instance_deployments: 15.2, + + leader_projects_prometheus_active: 0.31, + instance_projects_prometheus_active: 0.30, + + leader_service_desk_issues: 15.8, + instance_service_desk_issues: 15.1 + } + } + end + + def stub_response(body) + stub_request(:post, 'https://version.gitlab.com/usage_data'). + to_return( + headers: { 'Content-Type' => 'application/json' }, + body: body.to_json + ) + end +end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 7a9cd7553b1..c499b1bb343 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -733,6 +733,26 @@ describe SystemNoteService, services: true do jira_service_settings end + def cross_reference(type, link_exists = false) + noteable = type == 'commit' ? commit : merge_request + + links = [] + if link_exists + url = if type == 'commit' + "#{Settings.gitlab.base_url}/#{project.namespace.path}/#{project.path}/commit/#{commit.id}" + else + "#{Settings.gitlab.base_url}/#{project.namespace.path}/#{project.path}/merge_requests/#{merge_request.iid}" + end + link = double(object: { 'url' => url }) + links << link + expect(link).to receive(:save!) + end + + allow(JIRA::Resource::Remotelink).to receive(:all).and_return(links) + + described_class.cross_reference(jira_issue, noteable, author) + end + noteable_types = %w(merge_requests commit) noteable_types.each do |type| @@ -740,24 +760,39 @@ describe SystemNoteService, services: true do it "blocks cross reference when #{type.underscore}_events is false" do jira_tracker.update("#{type}_events" => false) - noteable = type == "commit" ? commit : merge_request - result = described_class.cross_reference(jira_issue, noteable, author) - - expect(result).to eq("Events for #{noteable.class.to_s.underscore.humanize.pluralize.downcase} are disabled.") + expect(cross_reference(type)).to eq("Events for #{type.pluralize.humanize.downcase} are disabled.") end it "blocks cross reference when #{type.underscore}_events is true" do jira_tracker.update("#{type}_events" => true) - noteable = type == "commit" ? commit : merge_request - result = described_class.cross_reference(jira_issue, noteable, author) + expect(cross_reference(type)).to eq(success_message) + end + end - expect(result).to eq(success_message) + context 'when a new cross reference is created' do + it 'creates a new comment and remote link' do + cross_reference(type) + + expect(WebMock).to have_requested(:post, jira_api_comment_url(jira_issue)) + expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)) + end + end + + context 'when a link exists' do + it 'updates a link but does not create a new comment' do + expect(WebMock).not_to have_requested(:post, jira_api_comment_url(jira_issue)) + + cross_reference(type, true) end end end describe "new reference" do + before do + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) + end + context 'for commits' do it "creates comment" do result = described_class.cross_reference(jira_issue, commit, author) @@ -837,6 +872,7 @@ describe SystemNoteService, services: true do describe "existing reference" do before do + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) message = "[#{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.chomp}'" allow_any_instance_of(JIRA::Resource::Issue).to receive(:comments).and_return([OpenStruct.new(body: message)]) end @@ -1034,4 +1070,35 @@ describe SystemNoteService, services: true do expect(subject.note).to eq 'resolved all discussions' end end + + describe '.diff_discussion_outdated' do + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } + let(:merge_request) { discussion.noteable } + let(:project) { merge_request.source_project } + let(:change_position) { discussion.position } + + def reloaded_merge_request + MergeRequest.find(merge_request.id) + end + + subject { described_class.diff_discussion_outdated(discussion, project, author, change_position) } + + it_behaves_like 'a system note' do + let(:expected_noteable) { discussion.first_note.noteable } + let(:action) { 'outdated' } + end + + it 'creates a new note in the discussion' do + # we need to completely rebuild the merge request object, or the `@discussions` on the merge request are not reloaded. + expect { subject }.to change { reloaded_merge_request.discussions.first.notes.size }.by(1) + end + + it 'links to the diff in the system note' do + expect(subject.note).to include('version 1') + + diff_id = merge_request.merge_request_diff.id + line_code = change_position.line_code(project.repository) + expect(subject.note).to include(diffs_namespace_project_merge_request_url(project.namespace, project, merge_request, diff_id: diff_id, anchor: line_code)) + end + end end diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb index de37a61e388..5409f67c091 100644 --- a/spec/services/users/destroy_service_spec.rb +++ b/spec/services/users/destroy_service_spec.rb @@ -147,16 +147,22 @@ describe Users::DestroyService, services: true do end context "migrating associated records" do + let!(:issue) { create(:issue, author: user) } + it 'delegates to the `MigrateToGhostUser` service to move associated records to the ghost user' do - expect_any_instance_of(Users::MigrateToGhostUserService).to receive(:execute).once + expect_any_instance_of(Users::MigrateToGhostUserService).to receive(:execute).once.and_call_original service.execute(user) + + expect(issue.reload.author).to be_ghost end it 'does not run `MigrateToGhostUser` if hard_delete option is given' do expect_any_instance_of(Users::MigrateToGhostUserService).not_to receive(:execute) service.execute(user, hard_delete: true) + + expect(Issue.exists?(issue.id)).to be_falsy end end end diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb new file mode 100644 index 00000000000..b5abc46e80c --- /dev/null +++ b/spec/services/web_hook_service_spec.rb @@ -0,0 +1,137 @@ +require 'spec_helper' + +describe WebHookService, services: true do + let(:project) { create(:empty_project) } + let(:project_hook) { create(:project_hook) } + let(:headers) do + { + 'Content-Type' => 'application/json', + 'X-Gitlab-Event' => 'Push Hook' + } + end + let(:data) do + { before: 'oldrev', after: 'newrev', ref: 'ref' } + end + let(:service_instance) { WebHookService.new(project_hook, data, 'push_hooks') } + + describe '#execute' do + before(:each) do + project.hooks << [project_hook] + + WebMock.stub_request(:post, project_hook.url) + end + + context 'when token is defined' do + let(:project_hook) { create(:project_hook, :token) } + + it 'POSTs to the webhook URL' do + service_instance.execute + expect(WebMock).to have_requested(:post, project_hook.url).with( + headers: headers.merge({ 'X-Gitlab-Token' => project_hook.token }) + ).once + end + end + + it 'POSTs to the webhook URL' do + service_instance.execute + expect(WebMock).to have_requested(:post, project_hook.url).with( + headers: headers + ).once + end + + it 'POSTs the data as JSON' do + service_instance.execute + expect(WebMock).to have_requested(:post, project_hook.url).with( + headers: headers + ).once + end + + it 'catches exceptions' do + WebMock.stub_request(:post, project_hook.url).to_raise(StandardError.new('Some error')) + + expect { service_instance.execute }.to raise_error(StandardError) + end + + it 'handles exceptions' do + exceptions = [SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout] + exceptions.each do |exception_class| + exception = exception_class.new('Exception message') + + WebMock.stub_request(:post, project_hook.url).to_raise(exception) + expect(service_instance.execute).to eq([nil, exception.message]) + expect { service_instance.execute }.not_to raise_error + end + end + + it 'handles 200 status code' do + WebMock.stub_request(:post, project_hook.url).to_return(status: 200, body: 'Success') + + expect(service_instance.execute).to eq([200, 'Success']) + end + + it 'handles 2xx status codes' do + WebMock.stub_request(:post, project_hook.url).to_return(status: 201, body: 'Success') + + expect(service_instance.execute).to eq([201, 'Success']) + end + + context 'execution logging' do + let(:hook_log) { project_hook.web_hook_logs.last } + + context 'with success' do + before do + WebMock.stub_request(:post, project_hook.url).to_return(status: 200, body: 'Success') + service_instance.execute + end + + it 'log successful execution' do + expect(hook_log.trigger).to eq('push_hooks') + expect(hook_log.url).to eq(project_hook.url) + expect(hook_log.request_headers).to eq(headers) + expect(hook_log.response_body).to eq('Success') + expect(hook_log.response_status).to eq('200') + expect(hook_log.execution_duration).to be > 0 + expect(hook_log.internal_error_message).to be_nil + end + end + + context 'with exception' do + before do + WebMock.stub_request(:post, project_hook.url).to_raise(SocketError.new('Some HTTP Post error')) + service_instance.execute + end + + it 'log failed execution' do + expect(hook_log.trigger).to eq('push_hooks') + expect(hook_log.url).to eq(project_hook.url) + expect(hook_log.request_headers).to eq(headers) + expect(hook_log.response_body).to eq('') + expect(hook_log.response_status).to eq('internal error') + expect(hook_log.execution_duration).to be > 0 + expect(hook_log.internal_error_message).to eq('Some HTTP Post error') + end + end + + context 'should not log ServiceHooks' do + let(:service_hook) { create(:service_hook) } + let(:service_instance) { WebHookService.new(service_hook, data, 'service_hook') } + + before do + WebMock.stub_request(:post, service_hook.url).to_return(status: 200, body: 'Success') + end + + it { expect { service_instance.execute }.not_to change(WebHookLog, :count) } + end + end + end + + describe '#async_execute' do + let(:system_hook) { create(:system_hook) } + + it 'enqueue WebHookWorker' do + expect(Sidekiq::Client).to receive(:enqueue).with(WebHookWorker, project_hook.id, data, 'push_hooks') + + WebHookService.new(project_hook, data, 'push_hooks').async_execute + end + end +end diff --git a/spec/services/wiki_pages/create_service_spec.rb b/spec/services/wiki_pages/create_service_spec.rb index 5341ba3d261..054e28ae7b0 100644 --- a/spec/services/wiki_pages/create_service_spec.rb +++ b/spec/services/wiki_pages/create_service_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe WikiPages::CreateService, services: true do let(:project) { create(:empty_project) } let(:user) { create(:user) } + let(:opts) do { title: 'Title', @@ -10,27 +11,28 @@ describe WikiPages::CreateService, services: true do format: 'markdown' } end - let(:service) { described_class.new(project, user, opts) } + + subject(:service) { described_class.new(project, user, opts) } + + before do + project.add_developer(user) + end describe '#execute' do - context "valid params" do - before do - allow(service).to receive(:execute_hooks) - project.add_master(user) - end - - subject { service.execute } - - it 'creates a valid wiki page' do - is_expected.to be_valid - expect(subject.title).to eq(opts[:title]) - expect(subject.content).to eq(opts[:content]) - expect(subject.format).to eq(opts[:format].to_sym) - end - - it 'executes webhooks' do - expect(service).to have_received(:execute_hooks).once.with(subject, 'create') - end + it 'creates wiki page with valid attributes' do + page = service.execute + + expect(page).to be_valid + expect(page.title).to eq(opts[:title]) + expect(page.content).to eq(opts[:content]) + expect(page.format).to eq(opts[:format].to_sym) + end + + it 'executes webhooks' do + expect(service).to receive(:execute_hooks).once + .with(instance_of(WikiPage), 'create') + + service.execute end end end diff --git a/spec/services/wiki_pages/destroy_service_spec.rb b/spec/services/wiki_pages/destroy_service_spec.rb index a4b9a390fe2..920be4d4c8a 100644 --- a/spec/services/wiki_pages/destroy_service_spec.rb +++ b/spec/services/wiki_pages/destroy_service_spec.rb @@ -3,19 +3,20 @@ require 'spec_helper' describe WikiPages::DestroyService, services: true do let(:project) { create(:empty_project) } let(:user) { create(:user) } - let(:wiki_page) { create(:wiki_page) } - let(:service) { described_class.new(project, user) } + let(:page) { create(:wiki_page) } - describe '#execute' do - before do - allow(service).to receive(:execute_hooks) - project.add_master(user) - end + subject(:service) { described_class.new(project, user) } + before do + project.add_developer(user) + end + + describe '#execute' do it 'executes webhooks' do - service.execute(wiki_page) + expect(service).to receive(:execute_hooks).once + .with(instance_of(WikiPage), 'delete') - expect(service).to have_received(:execute_hooks).once.with(wiki_page, 'delete') + service.execute(page) end end end diff --git a/spec/services/wiki_pages/update_service_spec.rb b/spec/services/wiki_pages/update_service_spec.rb index 2bccca764d7..5e36ea4cf94 100644 --- a/spec/services/wiki_pages/update_service_spec.rb +++ b/spec/services/wiki_pages/update_service_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' describe WikiPages::UpdateService, services: true do let(:project) { create(:empty_project) } let(:user) { create(:user) } - let(:wiki_page) { create(:wiki_page) } + let(:page) { create(:wiki_page) } + let(:opts) do { content: 'New content for wiki page', @@ -11,27 +12,28 @@ describe WikiPages::UpdateService, services: true do message: 'New wiki message' } end - let(:service) { described_class.new(project, user, opts) } + + subject(:service) { described_class.new(project, user, opts) } + + before do + project.add_developer(user) + end describe '#execute' do - context "valid params" do - before do - allow(service).to receive(:execute_hooks) - project.add_master(user) - end - - subject { service.execute(wiki_page) } - - it 'updates the wiki page' do - is_expected.to be_valid - expect(subject.content).to eq(opts[:content]) - expect(subject.format).to eq(opts[:format].to_sym) - expect(subject.message).to eq(opts[:message]) - end - - it 'executes webhooks' do - expect(service).to have_received(:execute_hooks).once.with(subject, 'update') - end + it 'updates the wiki page' do + updated_page = service.execute(page) + + expect(updated_page).to be_valid + expect(updated_page.message).to eq(opts[:message]) + expect(updated_page.content).to eq(opts[:content]) + expect(updated_page.format).to eq(opts[:format].to_sym) + end + + it 'executes webhooks' do + expect(service).to receive(:execute_hooks).once + .with(instance_of(WikiPage), 'update') + + service.execute(page) end end end diff --git a/spec/sidekiq/cron/job_gem_dependency_spec.rb b/spec/sidekiq/cron/job_gem_dependency_spec.rb new file mode 100644 index 00000000000..2e30cf025b0 --- /dev/null +++ b/spec/sidekiq/cron/job_gem_dependency_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe Sidekiq::Cron::Job do + describe 'cron jobs' do + context 'when rufus-scheduler depends on ZoTime or EoTime' do + before do + described_class + .create(name: 'TestCronWorker', + cron: Settings.cron_jobs[:pipeline_schedule_worker]['cron'], + class: Settings.cron_jobs[:pipeline_schedule_worker]['job_class']) + end + + it 'does not get "Rufus::Scheduler::ZoTime/EtOrbi::EoTime into an exact number"' do + expect { described_class.all.first.should_enque?(Time.now) }.not_to raise_error + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c126641c4b9..698472720c3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,7 @@ SimpleCovEnv.start! ENV["RAILS_ENV"] ||= 'test' ENV["IN_MEMORY_APPLICATION_SETTINGS"] = 'true' +# ENV['prometheus_multiproc_dir'] = 'tmp/prometheus_multiproc_dir_test' require File.expand_path("../../config/environment", __FILE__) require 'rspec/rails' @@ -10,7 +11,7 @@ require 'shoulda/matchers' require 'rspec/retry' rspec_profiling_is_configured = - ENV['RSPEC_PROFILING_POSTGRES_URL'] || + ENV['RSPEC_PROFILING_POSTGRES_URL'].present? || ENV['RSPEC_PROFILING'] branch_can_be_profiled = ENV['GITLAB_DATABASE'] == 'postgresql' && @@ -26,6 +27,9 @@ if ENV['CI'] && !ENV['NO_KNAPSACK'] Knapsack::Adapters::RSpecAdapter.bind end +# require rainbow gem String monkeypatch, so we can test SystemChecks +require 'rainbow/ext/string' + # Requires supporting ruby files with custom matchers and macros, etc, # in spec/support/ and its subdirectories. Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } @@ -44,7 +48,6 @@ RSpec.configure do |config| config.include LoginHelpers, type: :feature config.include SearchHelpers, type: :feature config.include WaitForRequests, :js - config.include WaitForAjax, :js config.include StubConfiguration config.include EmailHelpers, type: :mailer config.include TestEnv @@ -53,6 +56,7 @@ RSpec.configure do |config| config.include StubGitlabCalls config.include StubGitlabData config.include ApiHelpers, :api + config.include MigrationsHelpers, :migration config.infer_spec_type_from_file_location! @@ -94,6 +98,20 @@ RSpec.configure do |config| Sidekiq.redis(&:flushall) end +<<<<<<< HEAD +======= + config.around(:example, :migration) do |example| + begin + ActiveRecord::Migrator + .migrate(migrations_paths, previous_migration.version) + + example.run + ensure + ActiveRecord::Migrator.migrate(migrations_paths) + end + end + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 config.around(:each, :nested_groups) do |example| example.run if Group.supports_nested_groups? end diff --git a/spec/support/controllers/githubish_import_controller_shared_examples.rb b/spec/support/controllers/githubish_import_controller_shared_examples.rb index c59b30c772d..d6b40db09ce 100644 --- a/spec/support/controllers/githubish_import_controller_shared_examples.rb +++ b/spec/support/controllers/githubish_import_controller_shared_examples.rb @@ -209,9 +209,13 @@ shared_examples 'a GitHub-ish import controller: POST create' do end context 'user has chosen a namespace and name for the project' do - let(:test_namespace) { create(:namespace, name: 'test_namespace', owner: user) } + let(:test_namespace) { create(:group, name: 'test_namespace') } let(:test_name) { 'test_name' } + before do + test_namespace.add_owner(user) + end + it 'takes the selected namespace and name' do expect(Gitlab::GithubImport::ProjectCreator). to receive(:new).with(provider_repo, test_name, test_namespace, user, access_params, type: provider). @@ -230,10 +234,14 @@ shared_examples 'a GitHub-ish import controller: POST create' do end context 'user has chosen an existing nested namespace and name for the project' do - let(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } - let(:nested_namespace) { create(:namespace, name: 'bar', parent: parent_namespace, owner: user) } + let(:parent_namespace) { create(:group, name: 'foo', owner: user) } + let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) } let(:test_name) { 'test_name' } + before do + nested_namespace.add_owner(user) + end + it 'takes the selected namespace and name' do expect(Gitlab::GithubImport::ProjectCreator). to receive(:new).with(provider_repo, test_name, nested_namespace, user, access_params, type: provider). @@ -276,7 +284,7 @@ shared_examples 'a GitHub-ish import controller: POST create' do context 'user has chosen existent and non-existent nested namespaces and name for the project' do let(:test_name) { 'test_name' } - let!(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } + let!(:parent_namespace) { create(:group, name: 'foo', owner: user) } it 'takes the selected namespace and name' do expect(Gitlab::GithubImport::ProjectCreator). diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb index 8ad042f5e3b..6e1eb5c678d 100644 --- a/spec/support/cycle_analytics_helpers.rb +++ b/spec/support/cycle_analytics_helpers.rb @@ -20,7 +20,7 @@ module CycleAnalyticsHelpers ref: 'refs/heads/master').execute end - def create_merge_request_closing_issue(issue, message: nil, source_branch: nil) + def create_merge_request_closing_issue(issue, message: nil, source_branch: nil, commit_message: 'commit message') if !source_branch || project.repository.commit(source_branch).blank? source_branch = generate(:branch) project.repository.add_branch(user, source_branch, 'master') @@ -30,7 +30,7 @@ module CycleAnalyticsHelpers user, generate(:branch), 'content', - message: 'commit message', + message: commit_message, branch_name: source_branch) project.repository.commit(sha) @@ -51,12 +51,43 @@ module CycleAnalyticsHelpers end def deploy_master(environment: 'production') - CreateDeploymentService.new(project, user, { - environment: environment, - ref: 'master', - tag: false, - sha: project.repository.commit('master').sha - }).execute + dummy_job = + case environment + when 'production' + dummy_production_job + when 'staging' + dummy_staging_job + else + raise ArgumentError + end + + CreateDeploymentService.new(dummy_job).execute + end + + def dummy_production_job + @dummy_job ||= new_dummy_job('production') + end + + def dummy_staging_job + @dummy_job ||= new_dummy_job('staging') + end + + def dummy_pipeline + @dummy_pipeline ||= + Ci::Pipeline.new(sha: project.repository.commit('master').sha) + end + + def new_dummy_job(environment) + project.environments.find_or_create_by(name: environment) + + Ci::Build.new( + project: project, + user: user, + environment: environment, + ref: 'master', + tag: false, + name: 'dummy', + pipeline: dummy_pipeline) end end diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb index 6f31828b825..7f5769209bb 100644 --- a/spec/support/db_cleaner.rb +++ b/spec/support/db_cleaner.rb @@ -19,6 +19,10 @@ RSpec.configure do |config| DatabaseCleaner.strategy = :truncation end + config.before(:each, :migration) do + DatabaseCleaner.strategy = :truncation + end + config.before(:each) do DatabaseCleaner.start end diff --git a/spec/support/dropzone_helper.rb b/spec/support/dropzone_helper.rb index 984ec7d2741..02fdeb08afe 100644 --- a/spec/support/dropzone_helper.rb +++ b/spec/support/dropzone_helper.rb @@ -6,32 +6,52 @@ module DropzoneHelper # Dropzone events to perform the actual upload. # # This method waits for the upload to complete before returning. - def dropzone_file(file_path) + # max_file_size is an optional parameter. + # If it's not 0, then it used in dropzone.maxFilesize parameter. + # wait_for_queuecomplete is an optional parameter. + # If it's 'false', then the helper will NOT wait for backend response + # It lets to test behaviors while AJAX is processing. + def dropzone_file(files, max_file_size = 0, wait_for_queuecomplete = true) # Generate a fake file input that Capybara can attach to page.execute_script <<-JS.strip_heredoc + $('#fakeFileInput').remove(); var fakeFileInput = window.$('<input/>').attr( - {id: 'fakeFileInput', type: 'file'} + {id: 'fakeFileInput', type: 'file', multiple: true} ).appendTo('body'); window._dropzoneComplete = false; JS - # Attach the file to the fake input selector with Capybara - attach_file('fakeFileInput', file_path) + # Attach files to the fake input selector with Capybara + attach_file('fakeFileInput', files) # Manually trigger a Dropzone "drop" event with the fake input's file list page.execute_script <<-JS.strip_heredoc - var fileList = [$('#fakeFileInput')[0].files[0]]; - var e = jQuery.Event('drop', { dataTransfer : { files : fileList } }); - var dropzone = $('.div-dropzone')[0].dropzone; + dropzone.options.autoProcessQueue = false; + + if (#{max_file_size} > 0) { + dropzone.options.maxFilesize = #{max_file_size}; + } + dropzone.on('queuecomplete', function() { window._dropzoneComplete = true; }); - dropzone.listeners[0].events.drop(e); + + var fileList = [$('#fakeFileInput')[0].files]; + + $.map(fileList, function(file){ + var e = jQuery.Event('drop', { dataTransfer : { files : file } }); + + dropzone.listeners[0].events.drop(e); + }); + + dropzone.processQueue(); JS - # Wait until Dropzone's fired `queuecomplete` - loop until page.evaluate_script('window._dropzoneComplete === true') + if wait_for_queuecomplete + # Wait until Dropzone's fired `queuecomplete` + loop until page.evaluate_script('window._dropzoneComplete === true') + end end end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index ad46b163cd6..fa82dc5e9f9 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -22,7 +22,7 @@ shared_examples 'issuable record that supports slash commands in its description after do # Ensure all outstanding Ajax requests are complete to avoid database deadlocks - wait_for_ajax + wait_for_requests end describe "new #{issuable_type}", js: true do @@ -58,7 +58,7 @@ shared_examples 'issuable record that supports slash commands in its description expect(page).not_to have_content '/label ~bug' expect(page).not_to have_content '/milestone %"ASAP"' - wait_for_ajax + wait_for_requests issuable.reload note = issuable.notes.user.first diff --git a/spec/support/features/reportable_note_shared_examples.rb b/spec/support/features/reportable_note_shared_examples.rb new file mode 100644 index 00000000000..0d80c95e826 --- /dev/null +++ b/spec/support/features/reportable_note_shared_examples.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +shared_examples 'reportable note' do + include NotesHelper + + let(:comment) { find("##{ActionView::RecordIdentifier.dom_id(note)}") } + let(:more_actions_selector) { '.more-actions.dropdown' } + let(:abuse_report_path) { new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) } + + it 'has a `More actions` dropdown' do + expect(comment).to have_selector(more_actions_selector) + end + + it 'dropdown has Edit, Report and Delete links' do + dropdown = comment.find(more_actions_selector) + + dropdown.click + dropdown.find('.dropdown-menu li', match: :first) + + expect(dropdown).to have_button('Edit comment') + expect(dropdown).to have_link('Report as abuse', href: abuse_report_path) + expect(dropdown).to have_link('Delete comment', href: note_url(note, project)) + end + + it 'Report button links to a report page' do + dropdown = comment.find(more_actions_selector) + + dropdown.click + dropdown.find('.dropdown-menu li', match: :first) + + dropdown.click_link('Report as abuse') + + expect(find('#user_name')['value']).to match(note.author.username) + expect(find('#abuse_report_message')['value']).to match(noteable_note_url(note)) + end +end diff --git a/spec/support/features/rss_shared_examples.rb b/spec/support/features/rss_shared_examples.rb index 9a3b0a731ad..1cbb4134995 100644 --- a/spec/support/features/rss_shared_examples.rb +++ b/spec/support/features/rss_shared_examples.rb @@ -1,23 +1,23 @@ -shared_examples "an autodiscoverable RSS feed with current_user's private token" do - it "has an RSS autodiscovery link tag with current_user's private token" do - expect(page).to have_css("link[type*='atom+xml'][href*='private_token=#{Thread.current[:current_user].private_token}']", visible: false) +shared_examples "an autodiscoverable RSS feed with current_user's RSS token" do + it "has an RSS autodiscovery link tag with current_user's RSS token" do + expect(page).to have_css("link[type*='atom+xml'][href*='rss_token=#{Thread.current[:current_user].rss_token}']", visible: false) end end -shared_examples "it has an RSS button with current_user's private token" do - it "shows the RSS button with current_user's private token" do - expect(page).to have_css("a:has(.fa-rss)[href*='private_token=#{Thread.current[:current_user].private_token}']") +shared_examples "it has an RSS button with current_user's RSS token" do + it "shows the RSS button with current_user's RSS token" do + expect(page).to have_css("a:has(.fa-rss)[href*='rss_token=#{Thread.current[:current_user].rss_token}']") end end -shared_examples "an autodiscoverable RSS feed without a private token" do - it "has an RSS autodiscovery link tag without a private token" do - expect(page).to have_css("link[type*='atom+xml']:not([href*='private_token'])", visible: false) +shared_examples "an autodiscoverable RSS feed without an RSS token" do + it "has an RSS autodiscovery link tag without an RSS token" do + expect(page).to have_css("link[type*='atom+xml']:not([href*='rss_token'])", visible: false) end end -shared_examples "it has an RSS button without a private token" do - it "shows the RSS button without a private token" do - expect(page).to have_css("a:has(.fa-rss):not([href*='private_token'])") +shared_examples "it has an RSS button without an RSS token" do + it "shows the RSS button without an RSS token" do + expect(page).to have_css("a:has(.fa-rss):not([href*='rss_token'])") end end diff --git a/spec/support/filtered_search_helpers.rb b/spec/support/filtered_search_helpers.rb index 36be0bb6bf8..37cc308e613 100644 --- a/spec/support/filtered_search_helpers.rb +++ b/spec/support/filtered_search_helpers.rb @@ -73,11 +73,11 @@ module FilteredSearchHelpers end def remove_recent_searches - execute_script('window.localStorage.removeItem(\'issue-recent-searches\');') + execute_script('window.localStorage.clear();') end - def set_recent_searches(input) - execute_script("window.localStorage.setItem('issue-recent-searches', '#{input}');") + def set_recent_searches(key, input) + execute_script("window.localStorage.setItem('#{key}', '#{input}');") end def wait_for_filtered_search(text) diff --git a/spec/support/generate-seed-repo-rb b/spec/support/generate-seed-repo-rb new file mode 100755 index 00000000000..7335f74c0e9 --- /dev/null +++ b/spec/support/generate-seed-repo-rb @@ -0,0 +1,162 @@ +#!/usr/bin/env ruby +# +# # generate-seed-repo-rb +# +# This script generates the seed_repo.rb file used by lib/gitlab/git +# tests. The seed_repo.rb file needs to be updated anytime there is a +# Git push to https://gitlab.com/gitlab-org/gitlab-git-test. +# +# Usage: +# +# ./spec/support/generate-seed-repo-rb > spec/support/seed_repo.rb +# +# + +require 'erb' +require 'tempfile' + +SOURCE = 'https://gitlab.com/gitlab-org/gitlab-git-test.git'.freeze +SCRIPT_NAME = 'generate-seed-repo-rb'.freeze +REPO_NAME = 'gitlab-git-test.git'.freeze + +def main + Dir.mktmpdir do |dir| + unless system(*%W[git clone --bare #{SOURCE} #{REPO_NAME}], chdir: dir) + abort "git clone failed" + end + repo = File.join(dir, REPO_NAME) + erb = ERB.new(DATA.read) + erb.run(binding) + end +end + +def capture!(cmd, dir) + output = IO.popen(cmd, 'r', chdir: dir) { |io| io.read } + raise "command failed with #{$?}: #{cmd.join(' ')}" unless $?.success? + output.chomp +end + +main + +__END__ +# This file is generated by <%= SCRIPT_NAME %>. Do not edit this file manually. +# +# Seed repo: +<%= capture!(%w{git log --format=#\ %H\ %s}, repo) %> + +module SeedRepo + module BigCommit + ID = "913c66a37b4a45b9769037c55c2d238bd0942d2e".freeze + PARENT_ID = "cfe32cf61b73a0d5e9f13e774abde7ff789b1660".freeze + MESSAGE = "Files, encoding and much more".freeze + AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze + FILES_COUNT = 2 + end + + module Commit + ID = "570e7b2abdd848b95f2f578043fc23bd6f6fd24d".freeze + PARENT_ID = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9".freeze + MESSAGE = "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n".freeze + AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze + FILES = ["files/ruby/popen.rb", "files/ruby/regex.rb"].freeze + FILES_COUNT = 2 + C_FILE_PATH = "files/ruby".freeze + C_FILES = ["popen.rb", "regex.rb", "version_info.rb"].freeze + BLOB_FILE = %{%h3= @key.title\n%hr\n%pre= @key.key\n.actions\n = link_to 'Remove', @key, :confirm => 'Are you sure?', :method => :delete, :class => \"btn danger delete-key\"\n\n\n}.freeze + BLOB_FILE_PATH = "app/views/keys/show.html.haml".freeze + end + + module EmptyCommit + ID = "b0e52af38d7ea43cf41d8a6f2471351ac036d6c9".freeze + PARENT_ID = "40f4a7a617393735a95a0bb67b08385bc1e7c66d".freeze + MESSAGE = "Empty commit".freeze + AUTHOR_FULL_NAME = "Rémy Coutable".freeze + FILES = [].freeze + FILES_COUNT = FILES.count + end + + module EncodingCommit + ID = "40f4a7a617393735a95a0bb67b08385bc1e7c66d".freeze + PARENT_ID = "66028349a123e695b589e09a36634d976edcc5e8".freeze + MESSAGE = "Add ISO-8859-encoded file".freeze + AUTHOR_FULL_NAME = "Stan Hu".freeze + FILES = ["encoding/iso8859.txt"].freeze + FILES_COUNT = FILES.count + end + + module FirstCommit + ID = "1a0b36b3cdad1d2ee32457c102a8c0b7056fa863".freeze + PARENT_ID = nil + MESSAGE = "Initial commit".freeze + AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze + FILES = ["LICENSE", ".gitignore", "README.md"].freeze + FILES_COUNT = 3 + end + + module LastCommit + ID = <%= capture!(%w[git show -s --format=%H HEAD], repo).inspect %>.freeze + PARENT_ID = <%= capture!(%w[git show -s --format=%P HEAD], repo).split.last.inspect %>.freeze + MESSAGE = <%= capture!(%w[git show -s --format=%s HEAD], repo).inspect %>.freeze + AUTHOR_FULL_NAME = <%= capture!(%w[git show -s --format=%an HEAD], repo).inspect %>.freeze + FILES = <%= + parents = capture!(%w[git show -s --format=%P HEAD], repo).split + merge_base = parents.size > 1 ? capture!(%w[git merge-base] + parents, repo) : parents.first + capture!( %W[git diff --name-only #{merge_base}..HEAD --], repo).split("\n").inspect + %>.freeze + FILES_COUNT = FILES.count + end + + module Repo + HEAD = "master".freeze + BRANCHES = %w[ +<%= capture!(%W[git for-each-ref --format=#{' ' * 3}%(refname:strip=2) refs/heads/], repo) %> + ].freeze + TAGS = %w[ +<%= capture!(%W[git for-each-ref --format=#{' ' * 3}%(refname:strip=2) refs/tags/], repo) %> + ].freeze + end + + module RubyBlob + ID = "7e3e39ebb9b2bf433b4ad17313770fbe4051649c".freeze + NAME = "popen.rb".freeze + CONTENT = <<-eos.freeze +require 'fileutils' +require 'open3' + +module Popen + extend self + + def popen(cmd, path=nil) + unless cmd.is_a?(Array) + raise RuntimeError, "System commands must be given as an array of strings" + end + + path ||= Dir.pwd + + vars = { + "PWD" => path + } + + options = { + chdir: path + } + + unless File.directory?(path) + FileUtils.mkdir_p(path) + end + + @cmd_output = "" + @cmd_status = 0 + + Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr| + @cmd_output << stdout.read + @cmd_output << stderr.read + @cmd_status = wait_thr.value.exitstatus + end + + return @cmd_output, @cmd_status + end +end + eos + end +end diff --git a/spec/support/git_http_helpers.rb b/spec/support/git_http_helpers.rb index 46b686fce94..b8289e6c5f1 100644 --- a/spec/support/git_http_helpers.rb +++ b/spec/support/git_http_helpers.rb @@ -35,9 +35,14 @@ module GitHttpHelpers yield response end + def download_or_upload(*args, &block) + download(*args, &block) + upload(*args, &block) + end + def auth_env(user, password, spnego_request_token) env = workhorse_internal_api_request_header - if user && password + if user env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, password) elsif spnego_request_token env['HTTP_AUTHORIZATION'] = "Negotiate #{::Base64.strict_encode64('opaque_request_token')}" @@ -45,4 +50,19 @@ module GitHttpHelpers env end + + def git_access_error(error_key) + message = Gitlab::GitAccess::ERROR_MESSAGES[error_key] + message || raise("GitAccess error message key '#{error_key}' not found") + end + + def git_access_wiki_error(error_key) + message = Gitlab::GitAccessWiki::ERROR_MESSAGES[error_key] + message || raise("GitAccessWiki error message key '#{error_key}' not found") + end + + def change_access_error(error_key) + message = Gitlab::Checks::ChangeAccess::ERROR_MESSAGES[error_key] + message || raise("ChangeAccess error message key '#{error_key}' not found") + end end diff --git a/spec/support/gitaly.rb b/spec/support/gitaly.rb index 7aca902fc61..2bf159002a0 100644 --- a/spec/support/gitaly.rb +++ b/spec/support/gitaly.rb @@ -1,6 +1,7 @@ if Gitlab::GitalyClient.enabled? RSpec.configure do |config| - config.before(:each) do + config.before(:each) do |example| + next if example.metadata[:skip_gitaly_mock] allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true) end end diff --git a/spec/support/helpers/key_generator_helper.rb b/spec/support/helpers/key_generator_helper.rb new file mode 100644 index 00000000000..b1c289ffef7 --- /dev/null +++ b/spec/support/helpers/key_generator_helper.rb @@ -0,0 +1,41 @@ +module Spec + module Support + module Helpers + class KeyGeneratorHelper + # The components in a openssh .pub / known_host RSA public key. + RSA_COMPONENTS = ['ssh-rsa', :e, :n].freeze + + attr_reader :size + + def initialize(size = 2048) + @size = size + end + + def generate + key = OpenSSL::PKey::RSA.generate(size) + components = RSA_COMPONENTS.map do |component| + key.respond_to?(component) ? encode_mpi(key.public_send(component)) : component + end + + # Ruby tries to be helpful and adds new lines every 60 bytes :( + 'ssh-rsa ' + [pack_pubkey_components(components)].pack('m').delete("\n") + end + + private + + # Encodes an openssh-mpi-encoded integer. + def encode_mpi(n) + chars, n = [], n.to_i + chars << (n & 0xff) && n >>= 8 while n != 0 + chars << 0 if chars.empty? || chars.last >= 0x80 + chars.reverse.pack('C*') + end + + # Packs string components into an openssh-encoded pubkey. + def pack_pubkey_components(strings) + (strings.map { |s| [s.length].pack('N') }).zip(strings).flatten.join + end + end + end + end +end diff --git a/spec/support/helpers/note_interaction_helpers.rb b/spec/support/helpers/note_interaction_helpers.rb new file mode 100644 index 00000000000..551c759133c --- /dev/null +++ b/spec/support/helpers/note_interaction_helpers.rb @@ -0,0 +1,8 @@ +module NoteInteractionHelpers + def open_more_actions_dropdown(note) + note_element = find("#note_#{note.id}") + + note_element.find('.more-actions').click + note_element.find('.more-actions .dropdown-menu li', match: :first) + end +end diff --git a/spec/support/import_spec_helper.rb b/spec/support/import_spec_helper.rb index 6710962f082..d4eced724fa 100644 --- a/spec/support/import_spec_helper.rb +++ b/spec/support/import_spec_helper.rb @@ -28,6 +28,6 @@ module ImportSpecHelper app_id: 'asd123', app_secret: 'asd123' ) - allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider]) + stub_omniauth_setting(providers: [provider]) end end diff --git a/spec/support/issuable_shared_examples.rb b/spec/support/issuable_shared_examples.rb new file mode 100644 index 00000000000..03011535351 --- /dev/null +++ b/spec/support/issuable_shared_examples.rb @@ -0,0 +1,7 @@ +shared_examples 'cache counters invalidator' do + it 'invalidates counter cache for assignees' do + expect_any_instance_of(User).to receive(:invalidate_merge_request_cache_counts) + + described_class.new(project, user, {}).execute(merge_request) + end +end diff --git a/spec/support/javascript_fixtures_helpers.rb b/spec/support/javascript_fixtures_helpers.rb index a982b159b48..aace4b3adee 100644 --- a/spec/support/javascript_fixtures_helpers.rb +++ b/spec/support/javascript_fixtures_helpers.rb @@ -48,7 +48,7 @@ module JavaScriptFixturesHelpers link_tags = doc.css('link') link_tags.remove - scripts = doc.css("script:not([type='text/template'])") + scripts = doc.css("script:not([type='text/template']):not([type='text/x-template'])") scripts.remove fixture = doc.to_html diff --git a/spec/support/kubernetes_helpers.rb b/spec/support/kubernetes_helpers.rb index b5ed71ba3be..9280fad4ace 100644 --- a/spec/support/kubernetes_helpers.rb +++ b/spec/support/kubernetes_helpers.rb @@ -5,7 +5,7 @@ module KubernetesHelpers { "kind" => "APIResourceList", "resources" => [ - { "name" => "pods", "namespaced" => true, "kind" => "Pod" }, + { "name" => "pods", "namespaced" => true, "kind" => "Pod" } ] } end @@ -22,13 +22,13 @@ module KubernetesHelpers "metadata" => { "name" => "kube-pod", "creationTimestamp" => "2016-11-25T19:55:19Z", - "labels" => { "app" => app }, + "labels" => { "app" => app } }, "spec" => { "containers" => [ { "name" => "container-0" }, - { "name" => "container-1" }, - ], + { "name" => "container-1" } + ] }, "status" => { "phase" => "Running" } } @@ -41,7 +41,7 @@ module KubernetesHelpers containers.map do |container| terminal = { selectors: { pod: pod_name, container: container['name'] }, - url: container_exec_url(service.api_url, service.namespace, pod_name, container['name']), + url: container_exec_url(service.api_url, service.actual_namespace, pod_name, container['name']), subprotocols: ['channel.k8s.io'], headers: { 'Authorization' => ["Bearer #{service.token}"] }, created_at: DateTime.parse(pod['metadata']['creationTimestamp']), diff --git a/spec/support/matchers/execute_check.rb b/spec/support/matchers/execute_check.rb new file mode 100644 index 00000000000..7232fad52fb --- /dev/null +++ b/spec/support/matchers/execute_check.rb @@ -0,0 +1,23 @@ +RSpec::Matchers.define :execute_check do |expected| + match do |actual| + expect(actual).to eq(SystemCheck) + expect(actual).to receive(:run) do |*args| + expect(args[1]).to include(expected) + end + end + + match_when_negated do |actual| + expect(actual).to eq(SystemCheck) + expect(actual).to receive(:run) do |*args| + expect(args[1]).not_to include(expected) + end + end + + failure_message do |actual| + 'This matcher must be used with SystemCheck' unless actual == SystemCheck + end + + failure_message_when_negated do |actual| + 'This matcher must be used with SystemCheck' unless actual == SystemCheck + end +end diff --git a/spec/support/matchers/gitaly_matchers.rb b/spec/support/matchers/gitaly_matchers.rb index 65dbc01f6e4..ed14bcec9f2 100644 --- a/spec/support/matchers/gitaly_matchers.rb +++ b/spec/support/matchers/gitaly_matchers.rb @@ -1,3 +1,9 @@ RSpec::Matchers.define :gitaly_request_with_repo_path do |path| match { |actual| actual.repository.path == path } end + +RSpec::Matchers.define :gitaly_request_with_params do |params| + match do |actual| + params.reduce(true) { |r, (key, val)| r && actual.send(key) == val } + end +end diff --git a/spec/support/migrations_helpers.rb b/spec/support/migrations_helpers.rb new file mode 100644 index 00000000000..91fbb4eaf48 --- /dev/null +++ b/spec/support/migrations_helpers.rb @@ -0,0 +1,29 @@ +module MigrationsHelpers + def table(name) + Class.new(ActiveRecord::Base) { self.table_name = name } + end + + def migrations_paths + ActiveRecord::Migrator.migrations_paths + end + + def table_exists?(name) + ActiveRecord::Base.connection.table_exists?(name) + end + + def migrations + ActiveRecord::Migrator.migrations(migrations_paths) + end + + def previous_migration + migrations.each_cons(2) do |previous, migration| + break previous if migration.name == described_class.name + end + end + + def migrate! + ActiveRecord::Migrator.up(migrations_paths) do |migration| + migration.name == described_class.name + end + end +end diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb index 51987c7767d..55c11abe3f7 100644 --- a/spec/support/prometheus_helpers.rb +++ b/spec/support/prometheus_helpers.rb @@ -1,10 +1,16 @@ module PrometheusHelpers def prometheus_memory_query(environment_slug) - %{(sum(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"})) /1024/1024} + %{avg(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / 2^20} end def prometheus_cpu_query(environment_slug) - %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100} + %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100} + end + + def prometheus_ping_url(prometheus_query) + query = { query: prometheus_query }.to_query + + "https://prometheus.example.com/api/v1/query?#{query}" end def prometheus_ping_url(prometheus_query) diff --git a/spec/features/protected_branches/access_control_ce_spec.rb b/spec/support/protected_branches/access_control_ce_shared_examples.rb index d30e7947106..287d6bb13c3 100644 --- a/spec/features/protected_branches/access_control_ce_spec.rb +++ b/spec/support/protected_branches/access_control_ce_shared_examples.rb @@ -31,14 +31,14 @@ RSpec.shared_examples "protected branches > access control > CE" do within(".protected-branches-list") do find(".js-allowed-to-push").click - + within('.js-allowed-to-push-container') do expect(first("li")).to have_content("Roles") click_on access_type_name end end - wait_for_ajax + wait_for_requests expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id) end @@ -83,7 +83,7 @@ RSpec.shared_examples "protected branches > access control > CE" do end end - wait_for_ajax + wait_for_requests expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id) end diff --git a/spec/features/protected_tags/access_control_ce_spec.rb b/spec/support/protected_tags/access_control_ce_shared_examples.rb index 12622cd548a..1d11512ef82 100644 --- a/spec/features/protected_tags/access_control_ce_spec.rb +++ b/spec/support/protected_tags/access_control_ce_shared_examples.rb @@ -39,7 +39,7 @@ RSpec.shared_examples "protected tags > access control > CE" do end end - wait_for_ajax + wait_for_requests expect(ProtectedTag.last.create_access_levels.map(&:access_level)).to include(access_type_id) end diff --git a/spec/support/rake_helpers.rb b/spec/support/rake_helpers.rb index 4a8158ed79b..5cb415111d2 100644 --- a/spec/support/rake_helpers.rb +++ b/spec/support/rake_helpers.rb @@ -7,4 +7,9 @@ module RakeHelpers def stub_warn_user_is_not_gitlab allow_any_instance_of(Object).to receive(:warn_user_is_not_gitlab) end + + def silence_output + allow($stdout).to receive(:puts) + allow($stdout).to receive(:print) + end end diff --git a/spec/support/repo_helpers.rb b/spec/support/repo_helpers.rb index e9d5c7b12ae..3c6956cf5e0 100644 --- a/spec/support/repo_helpers.rb +++ b/spec/support/repo_helpers.rb @@ -92,11 +92,11 @@ eos changes = [ { line_code: 'a5cc2925ca8258af241be7e5b0381edf30266302_20_20', - file_path: '.gitignore', + file_path: '.gitignore' }, { line_code: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_4_6', - file_path: '.gitmodules', + file_path: '.gitmodules' } ] diff --git a/spec/support/seed_repo.rb b/spec/support/seed_repo.rb index 99a500bbbb1..cfe7fc980a8 100644 --- a/spec/support/seed_repo.rb +++ b/spec/support/seed_repo.rb @@ -1,4 +1,8 @@ +# This file is generated by generate-seed-repo-rb. Do not edit this file manually. +# # Seed repo: +# 4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6 Merge branch 'master' into 'master' +# 0e1b353b348f8477bdbec1ef47087171c5032cd9 adds an executable with different permissions # 0e50ec4d3c7ce42ab74dda1d422cb2cbffe1e326 Merge branch 'lfs_pointers' into 'master' # 33bcff41c232a11727ac6d660bd4b0c2ba86d63d Add valid and invalid lfs pointers # 732401c65e924df81435deb12891ef570167d2e2 Update year in license file @@ -94,7 +98,12 @@ module SeedRepo master merge-test ].freeze - TAGS = %w[v1.0.0 v1.1.0 v1.2.0 v1.2.1].freeze + TAGS = %w[ + v1.0.0 + v1.1.0 + v1.2.0 + v1.2.1 + ].freeze end module RubyBlob diff --git a/spec/support/snippets_shared_examples.rb b/spec/support/snippets_shared_examples.rb index 57dfff3471f..85f0facd5c3 100644 --- a/spec/support/snippets_shared_examples.rb +++ b/spec/support/snippets_shared_examples.rb @@ -7,7 +7,7 @@ RSpec.shared_examples 'paginated snippets' do |remote: false| context 'clicking on the link to the second page' do before do click_link('2') - wait_for_ajax if remote + wait_for_requests if remote end it 'shows the remaining snippets' do diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb index 444adcc1906..b39a23bd18a 100644 --- a/spec/support/stub_configuration.rb +++ b/spec/support/stub_configuration.rb @@ -25,6 +25,10 @@ module StubConfiguration allow(Gitlab.config.mattermost).to receive_messages(messages) end + def stub_omniauth_setting(messages) + allow(Gitlab.config.omniauth).to receive_messages(messages) + end + private # Modifies stubbed messages to also stub possible predicate versions diff --git a/spec/support/target_branch_helpers.rb b/spec/support/target_branch_helpers.rb index 3ee8f0f657e..01d1c53fe6c 100644 --- a/spec/support/target_branch_helpers.rb +++ b/spec/support/target_branch_helpers.rb @@ -1,7 +1,7 @@ module TargetBranchHelpers def select_branch(name) first('button.js-target-branch').click - wait_for_ajax + wait_for_requests all('a[data-group="Branches"]').find do |el| el.text == name end.click diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 9bf9dc5d4b2..3f472e59c49 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -40,8 +40,8 @@ module TestEnv 'wip' => 'b9238ee', 'csv' => '3dd0896', 'v1.1.0' => 'b83d6e3', - 'add-ipython-files' => '6d85bb69', - 'add-pdf-file' => 'e774ebd3' + 'add-ipython-files' => '93ee732', + 'add-pdf-file' => 'e774ebd' }.freeze # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily @@ -54,6 +54,8 @@ module TestEnv 'conflict-resolvable-fork' => '404fa3f' }.freeze + TMP_TEST_PATH = Rails.root.join('tmp', 'tests', '**') + # Test environment # # See gitlab.yml.example test section for paths @@ -98,9 +100,7 @@ module TestEnv # # Keeps gitlab-shell and gitlab-test def clean_test_path - tmp_test_path = Rails.root.join('tmp', 'tests', '**') - - Dir[tmp_test_path].each do |entry| + Dir[TMP_TEST_PATH].each do |entry| unless File.basename(entry) =~ /\A(gitaly|gitlab-(shell|test|test_bare|test-fork|test-fork_bare))\z/ FileUtils.rm_rf(entry) end @@ -111,6 +111,14 @@ module TestEnv FileUtils.mkdir_p(pages_path) end + def clean_gitlab_test_path + Dir[TMP_TEST_PATH].each do |entry| + if File.basename(entry) =~ /\A(gitlab-(test|test_bare|test-fork|test-fork_bare))\z/ + FileUtils.rm_rf(entry) + end + end + end + def setup_gitlab_shell unless File.directory?(Gitlab.config.gitlab_shell.path) unless system('rake', 'gitlab:shell:install') @@ -123,7 +131,7 @@ module TestEnv socket_path = Gitlab::GitalyClient.address('default').sub(/\Aunix:/, '') gitaly_dir = File.dirname(socket_path) - unless File.directory?(gitaly_dir) || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]") + unless !gitaly_needs_update?(gitaly_dir) || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]") raise "Can't clone gitaly" end @@ -155,14 +163,14 @@ module TestEnv FORKED_BRANCH_SHA) end - def setup_repo(repo_path, repo_path_bare, repo_name, branch_sha) + def setup_repo(repo_path, repo_path_bare, repo_name, refs) clone_url = "https://gitlab.com/gitlab-org/#{repo_name}.git" unless File.directory?(repo_path) system(*%W(#{Gitlab.config.git.bin_path} clone -q #{clone_url} #{repo_path})) end - set_repo_refs(repo_path, branch_sha) + set_repo_refs(repo_path, refs) unless File.directory?(repo_path_bare) # We must copy bare repositories because we will push to them. @@ -170,13 +178,12 @@ module TestEnv end end - def copy_repo(project) - base_repo_path = File.expand_path(factory_repo_path_bare) + def copy_repo(project, bare_repo:, refs:) target_repo_path = File.expand_path(project.repository_storage_path + "/#{project.full_path}.git") FileUtils.mkdir_p(target_repo_path) - FileUtils.cp_r("#{base_repo_path}/.", target_repo_path) + FileUtils.cp_r("#{File.expand_path(bare_repo)}/.", target_repo_path) FileUtils.chmod_R 0755, target_repo_path - set_repo_refs(target_repo_path, BRANCH_SHA) + set_repo_refs(target_repo_path, refs) end def repos_path @@ -191,15 +198,6 @@ module TestEnv Gitlab.config.pages.path end - def copy_forked_repo_with_submodules(project) - base_repo_path = File.expand_path(forked_repo_path_bare) - target_repo_path = File.expand_path(project.repository_storage_path + "/#{project.full_path}.git") - 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 # # Otherwise they'd be created by the first test, often timing out and @@ -211,16 +209,20 @@ module TestEnv Capybara.current_session.visit '/' end + def factory_repo_path_bare + "#{factory_repo_path}_bare" + end + + def forked_repo_path_bare + "#{forked_repo_path}_bare" + end + private def factory_repo_path @factory_repo_path ||= Rails.root.join('tmp', 'tests', factory_repo_name) end - def factory_repo_path_bare - "#{factory_repo_path}_bare" - end - def factory_repo_name 'gitlab-test' end @@ -229,10 +231,6 @@ module TestEnv @forked_repo_path ||= Rails.root.join('tmp', 'tests', forked_repo_name) end - def forked_repo_path_bare - "#{forked_repo_path}_bare" - end - def forked_repo_name 'gitlab-test-fork' end @@ -244,19 +242,33 @@ module TestEnv end def set_repo_refs(repo_path, branch_sha) - instructions = branch_sha.map {|branch, sha| "update refs/heads/#{branch}\x00#{sha}\x00" }.join("\x00") << "\x00" + instructions = branch_sha.map { |branch, sha| "update refs/heads/#{branch}\x00#{sha}\x00" }.join("\x00") << "\x00" update_refs = %W(#{Gitlab.config.git.bin_path} update-ref --stdin -z) reset = proc do - IO.popen(update_refs, "w") {|io| io.write(instructions) } - $?.success? + Dir.chdir(repo_path) do + IO.popen(update_refs, "w") { |io| io.write(instructions) } + $?.success? + end end - Dir.chdir(repo_path) do - # Try to reset without fetching to avoid using the network. - unless reset.call - raise 'Could not fetch test seed repository.' unless system(*%W(#{Gitlab.config.git.bin_path} fetch origin)) - raise 'The fetched test seed does not contain the required revision.' unless reset.call - end + # Try to reset without fetching to avoid using the network. + unless reset.call + raise 'Could not fetch test seed repository.' unless system(*%W(#{Gitlab.config.git.bin_path} -C #{repo_path} fetch origin)) + + # Before we used Git clone's --mirror option, bare repos could end up + # with missing refs, clearing them and retrying should fix the issue. + cleanup && clean_gitlab_test_path && init unless reset.call end end + + def gitaly_needs_update?(gitaly_dir) + gitaly_version = File.read(File.join(gitaly_dir, 'VERSION')).strip + + # Notice that this will always yield true when using branch versions + # (`=branch_name`), but that actually makes sure the server is always based + # on the latest branch revision. + gitaly_version != Gitlab::GitalyClient.expected_server_version + rescue Errno::ENOENT + true + end end diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb index 84ef46ffa27..b407b8097d2 100644 --- a/spec/support/time_tracking_shared_examples.rb +++ b/spec/support/time_tracking_shared_examples.rb @@ -8,7 +8,7 @@ shared_examples 'issuable time tracker' do it 'updates the sidebar component when estimate is added' do submit_time('/estimate 3w 1d 1h') - wait_for_ajax + wait_for_requests page.within '.time-tracking-estimate-only-pane' do expect(page).to have_content '3w 1d 1h' end @@ -17,7 +17,7 @@ shared_examples 'issuable time tracker' do it 'updates the sidebar component when spent is added' do submit_time('/spend 3w 1d 1h') - wait_for_ajax + wait_for_requests page.within '.time-tracking-spend-only-pane' do expect(page).to have_content '3w 1d 1h' end @@ -27,7 +27,7 @@ shared_examples 'issuable time tracker' do submit_time('/estimate 3w 1d 1h') submit_time('/spend 3w 1d 1h') - wait_for_ajax + wait_for_requests page.within '.time-tracking-comparison-pane' do expect(page).to have_content '3w 1d 1h' end @@ -81,5 +81,5 @@ end def submit_time(slash_command) fill_in 'note[note]', with: slash_command find('.js-comment-submit-button').trigger('click') - wait_for_ajax + wait_for_requests end diff --git a/spec/support/wait_for_ajax.rb b/spec/support/wait_for_ajax.rb deleted file mode 100644 index 508de2ee8e1..00000000000 --- a/spec/support/wait_for_ajax.rb +++ /dev/null @@ -1,18 +0,0 @@ -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? - return true unless javascript_test? - return true if page.evaluate_script('typeof jQuery === "undefined"') - - page.evaluate_script('jQuery.active').zero? - end - - def javascript_test? - Capybara.current_driver == Capybara.javascript_driver - end -end diff --git a/spec/support/wait_for_requests.rb b/spec/support/wait_for_requests.rb index d41e83ae128..cd84b01f499 100644 --- a/spec/support/wait_for_requests.rb +++ b/spec/support/wait_for_requests.rb @@ -1,3 +1,4 @@ +<<<<<<< HEAD require_relative './wait_for_ajax' require_relative './wait_for_vue_resource' @@ -5,17 +6,40 @@ module WaitForRequests extend self include WaitForAjax include WaitForVueResource +======= +require_relative './wait_for_requests' + +module WaitForRequests + extend self +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 # This is inspired by http://www.salsify.com/blog/engineering/tearing-capybara-ajax-tests - def wait_for_requests_complete + def block_and_wait_for_requests_complete Gitlab::Testing::RequestBlockerMiddleware.block_requests! +<<<<<<< HEAD wait_for('pending AJAX requests complete') do +======= + wait_for('pending requests complete') do +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 Gitlab::Testing::RequestBlockerMiddleware.num_active_requests.zero? end ensure Gitlab::Testing::RequestBlockerMiddleware.allow_requests! end + def wait_for_requests + wait_for('JS requests') { finished_all_requests? } + end + + private + + def finished_all_requests? + return true unless javascript_test? + + finished_all_ajax_requests? && + finished_all_vue_resource_requests? + end + # Waits until the passed block returns true def wait_for(condition_name, max_wait_time: Capybara.default_max_wait_time, polling_interval: 0.01) wait_until = Time.now + max_wait_time.seconds @@ -28,10 +52,24 @@ module WaitForRequests end end end + + def finished_all_vue_resource_requests? + page.evaluate_script('window.activeVueResources || 0').zero? + end + + def finished_all_ajax_requests? + return true if page.evaluate_script('typeof jQuery === "undefined"') + + page.evaluate_script('jQuery.active').zero? + end + + def javascript_test? + Capybara.current_driver == Capybara.javascript_driver + end end RSpec.configure do |config| config.after(:each, :js) do - wait_for_requests_complete + block_and_wait_for_requests_complete end end diff --git a/spec/support/workhorse_helpers.rb b/spec/support/workhorse_helpers.rb index 47673cd4c3a..ef1f9f68671 100644 --- a/spec/support/workhorse_helpers.rb +++ b/spec/support/workhorse_helpers.rb @@ -9,7 +9,7 @@ module WorkhorseHelpers header = split_header.join(':') [ type, - JSON.parse(Base64.urlsafe_decode64(header)), + JSON.parse(Base64.urlsafe_decode64(header)) ] end end diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb index aaf998a546f..4a636decafd 100644 --- a/spec/tasks/gitlab/gitaly_rake_spec.rb +++ b/spec/tasks/gitlab/gitaly_rake_spec.rb @@ -80,28 +80,28 @@ describe 'gitlab:gitaly namespace rake task' do it 'prints storage configuration in a TOML format' do config = { 'default' => { 'path' => '/path/to/default' }, - 'nfs_01' => { 'path' => '/path/to/nfs_01' }, + 'nfs_01' => { 'path' => '/path/to/nfs_01' } } allow(Gitlab.config.repositories).to receive(:storages).and_return(config) - orig_stdout = $stdout - $stdout = StringIO.new - - header = '' + expected_output = '' Timecop.freeze do - header = <<~TOML + expected_output = <<~TOML # Gitaly storage configuration generated from #{Gitlab.config.source} on #{Time.current.to_s(:long)} # This is in TOML format suitable for use in Gitaly's config.toml file. + [[storage]] + name = "default" + path = "/path/to/default" + [[storage]] + name = "nfs_01" + path = "/path/to/nfs_01" TOML - run_rake_task('gitlab:gitaly:storage_config') end - output = $stdout.string - $stdout = orig_stdout - - expect(output).to include(header) + expect { run_rake_task('gitlab:gitaly:storage_config')}. + to output(expected_output).to_stdout - parsed_output = TOML.parse(output) + parsed_output = TOML.parse(expected_output) config.each do |name, params| expect(parsed_output['storage']).to include({ 'name' => name, 'path' => params['path'] }) end diff --git a/spec/tasks/tokens_spec.rb b/spec/tasks/tokens_spec.rb index 19036c7677c..b84137eb365 100644 --- a/spec/tasks/tokens_spec.rb +++ b/spec/tasks/tokens_spec.rb @@ -18,4 +18,10 @@ describe 'tokens rake tasks' do expect { run_rake_task('tokens:reset_all_email') }.to change { user.reload.incoming_email_token } end end + + describe 'reset_all_rss task' do + it 'invokes create_hooks task' do + expect { run_rake_task('tokens:reset_all_rss') }.to change { user.reload.rss_token } + end + end end diff --git a/spec/uploaders/artifact_uploader_spec.rb b/spec/uploaders/artifact_uploader_spec.rb new file mode 100644 index 00000000000..24e2e3a9f0e --- /dev/null +++ b/spec/uploaders/artifact_uploader_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +describe ArtifactUploader do + let(:job) { create(:ci_build) } + let(:uploader) { described_class.new(job, :artifacts_file) } + let(:path) { Gitlab.config.artifacts.path } + + describe '.local_artifacts_store' do + subject { described_class.local_artifacts_store } + + it "delegate to artifacts path" do + expect(Gitlab.config.artifacts).to receive(:path) + + subject + end + end + + describe '.artifacts_upload_path' do + subject { described_class.artifacts_upload_path } + + it { is_expected.to start_with(path) } + it { is_expected.to end_with('tmp/uploads/') } + end + + describe '#store_dir' do + subject { uploader.store_dir } + + it { is_expected.to start_with(path) } + it { is_expected.to end_with("#{job.project_id}/#{job.id}") } + end + + describe '#cache_dir' do + subject { uploader.cache_dir } + + it { is_expected.to start_with(path) } + it { is_expected.to end_with('tmp/cache') } + end +end diff --git a/spec/uploaders/file_mover_spec.rb b/spec/uploaders/file_mover_spec.rb new file mode 100644 index 00000000000..896cb410ed5 --- /dev/null +++ b/spec/uploaders/file_mover_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +describe FileMover do + let(:filename) { 'banana_sample.gif' } + let(:file) { fixture_file_upload(Rails.root.join('spec', 'fixtures', filename)) } + let(:temp_description) do + 'test  same ![banana_sample]'\ + '(/uploads/temp/secret55/banana_sample.gif)' + end + let(:temp_file_path) { File.join('secret55', filename).to_s } + let(:file_path) { File.join('uploads', 'personal_snippet', snippet.id.to_s, 'secret55', filename).to_s } + + let(:snippet) { create(:personal_snippet, description: temp_description) } + + subject { described_class.new(file_path, snippet).execute } + + describe '#execute' do + before do + expect(FileUtils).to receive(:mkdir_p).with(a_string_including(File.dirname(file_path))) + expect(FileUtils).to receive(:move).with(a_string_including(temp_file_path), a_string_including(file_path)) + allow_any_instance_of(CarrierWave::SanitizedFile).to receive(:exists?).and_return(true) + allow_any_instance_of(CarrierWave::SanitizedFile).to receive(:size).and_return(10) + end + + context 'when move and field update successful' do + it 'updates the description correctly' do + subject + + expect(snippet.reload.description) + .to eq( + "test "\ + " same " + ) + end + + it 'creates a new update record' do + expect { subject }.to change { Upload.count }.by(1) + end + end + + context 'when update_markdown fails' do + before do + expect(FileUtils).to receive(:move).with(a_string_including(file_path), a_string_including(temp_file_path)) + end + + subject { described_class.new(file_path, snippet, :non_existing_field).execute } + + it 'does not update the description' do + subject + + expect(snippet.reload.description) + .to eq( + "test "\ + " same " + ) + end + + it 'does not create a new update record' do + expect { subject }.not_to change { Upload.count } + end + end + end +end diff --git a/spec/uploaders/gitlab_uploader_spec.rb b/spec/uploaders/gitlab_uploader_spec.rb new file mode 100644 index 00000000000..78e9d9cf46c --- /dev/null +++ b/spec/uploaders/gitlab_uploader_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' +require 'carrierwave/storage/fog' + +describe GitlabUploader do + let(:uploader_class) { Class.new(described_class) } + + subject { uploader_class.new } + + describe '#file_storage?' do + context 'when file storage is used' do + before do + uploader_class.storage(:file) + end + + it { is_expected.to be_file_storage } + end + + context 'when is remote storage' do + before do + uploader_class.storage(:fog) + end + + it { is_expected.not_to be_file_storage } + end + end + + describe '#file_cache_storage?' do + context 'when file storage is used' do + before do + uploader_class.cache_storage(:file) + end + + it { is_expected.to be_file_cache_storage } + end + + context 'when is remote storage' do + before do + uploader_class.cache_storage(:fog) + end + + it { is_expected.not_to be_file_cache_storage } + end + end + + describe '#move_to_cache' do + it 'is true' do + expect(subject.move_to_cache).to eq(true) + end + end + + describe '#move_to_store' do + it 'is true' do + expect(subject.move_to_store).to eq(true) + end + end +end diff --git a/spec/uploaders/lfs_object_uploader_spec.rb b/spec/uploaders/lfs_object_uploader_spec.rb new file mode 100644 index 00000000000..c3b72e7d677 --- /dev/null +++ b/spec/uploaders/lfs_object_uploader_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe LfsObjectUploader do + let(:uploader) { described_class.new(build_stubbed(:empty_project)) } + + describe '#cache!' do + it 'caches the file in the cache directory' do + # One to get the work dir, the other to remove it + expect(uploader).to receive(:workfile_path).exactly(2).times.and_call_original + expect(FileUtils).to receive(:mv).with(anything, /^#{uploader.work_dir}/).and_call_original + expect(FileUtils).to receive(:mv).with(/^#{uploader.work_dir}/, /^#{uploader.cache_dir}/).and_call_original + + fixture = Rails.root.join('spec', 'fixtures', 'rails_sample.jpg') + uploader.cache!(fixture_file_upload(fixture)) + + expect(uploader.file.path).to start_with(uploader.cache_dir) + end + end + + describe '#move_to_cache' do + it 'is true' do + expect(uploader.move_to_cache).to eq(true) + end + end + + describe '#move_to_store' do + it 'is true' do + expect(uploader.move_to_store).to eq(true) + end + end +end diff --git a/spec/uploaders/records_uploads_spec.rb b/spec/uploaders/records_uploads_spec.rb index 5c26e334a6e..bb32ee62ccb 100644 --- a/spec/uploaders/records_uploads_spec.rb +++ b/spec/uploaders/records_uploads_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe RecordsUploads do - let(:uploader) do + let!(:uploader) do class RecordsUploadsExampleUploader < GitlabUploader include RecordsUploads @@ -57,6 +57,13 @@ describe RecordsUploads do uploader.store!(upload_fixture('rails_sample.jpg')) end + it 'does not create an Upload record if model is missing' do + expect_any_instance_of(RecordsUploadsExampleUploader).to receive(:model).and_return(nil) + expect(Upload).not_to receive(:record).with(uploader) + + uploader.store!(upload_fixture('rails_sample.jpg')) + end + it 'it destroys Upload records at the same path before recording' do existing = Upload.create!( path: File.join('uploads', 'rails_sample.jpg'), diff --git a/spec/validators/dynamic_path_validator_spec.rb b/spec/validators/dynamic_path_validator_spec.rb index 03e23781d1b..696c28ec776 100644 --- a/spec/validators/dynamic_path_validator_spec.rb +++ b/spec/validators/dynamic_path_validator_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe DynamicPathValidator do let(:validator) { described_class.new(attributes: [:path]) } +<<<<<<< HEAD describe '#path_valid_for_record?' do context 'for project' do it 'calls valid_project_path?' do @@ -12,8 +13,30 @@ describe DynamicPathValidator do expect(validator.path_valid_for_record?(project, 'activity')).to be_truthy end +======= + def expect_handles_invalid_utf8 + expect { yield('\255invalid') }.to be_falsey + end + + describe '.valid_user_path' do + it 'handles invalid utf8' do + expect(described_class.valid_user_path?("a\0weird\255path")).to be_falsey + end + end + + describe '.valid_group_path' do + it 'handles invalid utf8' do + expect(described_class.valid_group_path?("a\0weird\255path")).to be_falsey + end + end + + describe '.valid_project_path' do + it 'handles invalid utf8' do + expect(described_class.valid_project_path?("a\0weird\255path")).to be_falsey +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 end +<<<<<<< HEAD context 'for group' do it 'calls valid_namespace_path?' do group = build(:group, :nested, path: 'activity') @@ -41,6 +64,46 @@ describe DynamicPathValidator do expect(described_class).to receive(:valid_namespace_path?).with(namespace.full_path).and_call_original +======= + describe '#path_valid_for_record?' do + context 'for project' do + it 'calls valid_project_path?' do + project = build(:project, path: 'activity') + + expect(described_class).to receive(:valid_project_path?).with(project.full_path).and_call_original + + expect(validator.path_valid_for_record?(project, 'activity')).to be_truthy + end + end + + context 'for group' do + it 'calls valid_group_path?' do + group = build(:group, :nested, path: 'activity') + + expect(described_class).to receive(:valid_group_path?).with(group.full_path).and_call_original + + expect(validator.path_valid_for_record?(group, 'activity')).to be_falsey + end + end + + context 'for user' do + it 'calls valid_user_path?' do + user = build(:user, username: 'activity') + + expect(described_class).to receive(:valid_user_path?).with(user.full_path).and_call_original + + expect(validator.path_valid_for_record?(user, 'activity')).to be_truthy + end + end + + context 'for user namespace' do + it 'calls valid_user_path?' do + user = create(:user, username: 'activity') + namespace = user.namespace + + expect(described_class).to receive(:valid_user_path?).with(namespace.full_path).and_call_original + +>>>>>>> abc61f260074663e5711d3814d9b7d301d07a259 expect(validator.path_valid_for_record?(namespace, 'activity')).to be_truthy end end @@ -52,7 +115,7 @@ describe DynamicPathValidator do validator.validate_each(group, :path, "Path with spaces, and comma's!") - expect(group.errors[:path]).to include(Gitlab::Regex.namespace_regex_message) + expect(group.errors[:path]).to include(Gitlab::PathRegex.namespace_format_message) end it 'adds a message when the path is not in the correct format' do diff --git a/spec/views/ci/status/_badge.html.haml_spec.rb b/spec/views/ci/status/_badge.html.haml_spec.rb index c62450fb8e2..72323da2838 100644 --- a/spec/views/ci/status/_badge.html.haml_spec.rb +++ b/spec/views/ci/status/_badge.html.haml_spec.rb @@ -16,7 +16,7 @@ describe 'ci/status/_badge', :view do end it 'has link to build details page' do - details_path = namespace_project_build_path( + details_path = namespace_project_job_path( project.namespace, project, build) render_status(build) diff --git a/spec/views/projects/_last_commit.html.haml_spec.rb b/spec/views/projects/_last_commit.html.haml_spec.rb deleted file mode 100644 index eea1695b171..00000000000 --- a/spec/views/projects/_last_commit.html.haml_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -require 'spec_helper' - -describe 'projects/_last_commit', :view do - let(:project) { create(:project, :repository) } - - context 'when there is a pipeline present for the commit' do - context 'when pipeline is blocked' do - let!(:pipeline) do - create(:ci_pipeline, :blocked, project: project, - sha: project.commit.id) - end - - it 'shows correct pipeline badge' do - render 'projects/last_commit', commit: project.commit, - project: project, - ref: :master - - expect(rendered).to have_text "blocked #{project.commit.short_id}" - end - end - end -end diff --git a/spec/views/projects/blob/_viewer.html.haml_spec.rb b/spec/views/projects/blob/_viewer.html.haml_spec.rb index 501f90c5f9a..bbd7f98fa8d 100644 --- a/spec/views/projects/blob/_viewer.html.haml_spec.rb +++ b/spec/views/projects/blob/_viewer.html.haml_spec.rb @@ -10,9 +10,9 @@ describe 'projects/blob/_viewer.html.haml', :view do include BlobViewer::Rich self.partial_name = 'text' - self.max_size = 1.megabyte - self.absolute_max_size = 5.megabytes - self.client_side = false + self.collapse_limit = 1.megabyte + self.size_limit = 5.megabytes + self.load_async = true end end @@ -35,9 +35,9 @@ describe 'projects/blob/_viewer.html.haml', :view do render partial: 'projects/blob/viewer', locals: { viewer: viewer } end - context 'when the viewer is server side' do + context 'when the viewer is loaded asynchronously' do before do - viewer_class.client_side = false + viewer_class.load_async = true end context 'when there is no render error' do @@ -47,10 +47,10 @@ describe 'projects/blob/_viewer.html.haml', :view do expect(rendered).to have_css('.blob-viewer[data-url]') end - it 'displays a spinner' do + it 'renders the loading indicator' do render_view - expect(rendered).to have_css('i[aria-label="Loading content"]') + expect(view).to render_template('projects/blob/viewers/_loading') end end @@ -65,9 +65,9 @@ describe 'projects/blob/_viewer.html.haml', :view do end end - context 'when the viewer is client side' do + context 'when the viewer is loaded synchronously' do before do - viewer_class.client_side = true + viewer_class.load_async = false end context 'when there is no render error' do diff --git a/spec/views/projects/builds/_build.html.haml_spec.rb b/spec/views/projects/jobs/_build.html.haml_spec.rb index 751482cac42..1d58891036e 100644 --- a/spec/views/projects/builds/_build.html.haml_spec.rb +++ b/spec/views/projects/jobs/_build.html.haml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'projects/ci/builds/_build' do +describe 'projects/ci/jobs/_build' do include Devise::Test::ControllerHelpers let(:project) { create(:project, :repository) } diff --git a/spec/views/projects/builds/_generic_commit_status.html.haml_spec.rb b/spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb index dc2ffc9dc47..dc2ffc9dc47 100644 --- a/spec/views/projects/builds/_generic_commit_status.html.haml_spec.rb +++ b/spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/jobs/show.html.haml_spec.rb index 0f39df0f250..8f2822f5dc5 100644 --- a/spec/views/projects/builds/show.html.haml_spec.rb +++ b/spec/views/projects/jobs/show.html.haml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'projects/builds/show', :view do +describe 'projects/jobs/show', :view do let(:project) { create(:project, :repository) } let(:build) { create(:ci_build, pipeline: pipeline) } @@ -278,7 +278,7 @@ describe 'projects/builds/show', :view do it 'links to issues/new with the title and description filled in' do title = "Build Failed ##{build.id}" - build_url = namespace_project_build_url(project.namespace, project, build) + build_url = namespace_project_job_url(project.namespace, project, build) href = new_namespace_project_issue_path( project.namespace, project, diff --git a/spec/views/projects/tree/show.html.haml_spec.rb b/spec/views/projects/tree/show.html.haml_spec.rb index 900f8d4732f..33eba3e6d3d 100644 --- a/spec/views/projects/tree/show.html.haml_spec.rb +++ b/spec/views/projects/tree/show.html.haml_spec.rb @@ -21,17 +21,17 @@ describe 'projects/tree/show' do let(:tree) { repository.tree(commit.id, path) } before do + assign(:id, File.join(ref, path)) assign(:ref, ref) - assign(:commit, commit) - assign(:id, commit.id) - assign(:tree, tree) assign(:path, path) + assign(:last_commit, commit) + assign(:tree, tree) end it 'displays correctly' do render expect(rendered).to have_css('.js-project-refs-dropdown .dropdown-toggle-text', text: ref) - expect(rendered).to have_css('.readme-holder .file-content', text: ref) + expect(rendered).to have_css('.readme-holder') end end end diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb index 7a590f64e3c..8c5303b61cc 100644 --- a/spec/workers/git_garbage_collect_worker_spec.rb +++ b/spec/workers/git_garbage_collect_worker_spec.rb @@ -105,7 +105,7 @@ describe GitGarbageCollectWorker do author: Gitlab::Git.committer_hash(email: 'foo@bar', name: 'baz'), committer: Gitlab::Git.committer_hash(email: 'foo@bar', name: 'baz'), tree: old_commit.tree, - parents: [old_commit], + parents: [old_commit] ) GitOperationService.new(nil, project.repository).send( :update_ref, diff --git a/spec/workers/gitlab_usage_ping_worker_spec.rb b/spec/workers/gitlab_usage_ping_worker_spec.rb index 26241044533..49b4e04dc7c 100644 --- a/spec/workers/gitlab_usage_ping_worker_spec.rb +++ b/spec/workers/gitlab_usage_ping_worker_spec.rb @@ -3,21 +3,11 @@ require 'spec_helper' describe GitlabUsagePingWorker do subject { described_class.new } - it "sends POST request" do - stub_application_setting(usage_ping_enabled: true) + it 'delegates to SubmitUsagePingService' do + allow(subject).to receive(:try_obtain_lease).and_return(true) - stub_request(:post, "https://version.gitlab.com/usage_data"). - to_return(status: 200, body: '', headers: {}) - expect(Gitlab::UsageData).to receive(:to_json).with({ force_refresh: true }).and_call_original - expect(subject).to receive(:try_obtain_lease).and_return(true) + expect_any_instance_of(SubmitUsagePingService).to receive(:execute) - expect(subject.perform.response.code.to_i).to eq(200) - end - - it "does not run if usage ping is disabled" do - stub_application_setting(usage_ping_enabled: false) - - expect(subject).not_to receive(:try_obtain_lease) - expect(subject).not_to receive(:perform) + subject.perform end end diff --git a/spec/workers/namespaceless_project_destroy_worker_spec.rb b/spec/workers/namespaceless_project_destroy_worker_spec.rb new file mode 100644 index 00000000000..8533b7b85e9 --- /dev/null +++ b/spec/workers/namespaceless_project_destroy_worker_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe NamespacelessProjectDestroyWorker do + subject { described_class.new } + + before do + # Stub after_save callbacks that will fail when Project has no namespace + allow_any_instance_of(Project).to receive(:ensure_dir_exist).and_return(nil) + allow_any_instance_of(Project).to receive(:update_project_statistics).and_return(nil) + end + + describe '#perform' do + context 'project has namespace' do + it 'does not do anything' do + project = create(:empty_project) + + subject.perform(project.id) + + expect(Project.unscoped.all).to include(project) + end + end + + context 'project has no namespace' do + let!(:project) do + project = build(:empty_project, namespace_id: nil) + project.save(validate: false) + project + end + + context 'project not a fork of another project' do + it "truncates the project's team" do + expect_any_instance_of(ProjectTeam).to receive(:truncate) + + subject.perform(project.id) + end + + it 'deletes the project' do + subject.perform(project.id) + + expect(Project.unscoped.all).not_to include(project) + end + + it 'does not call unlink_fork' do + is_expected.not_to receive(:unlink_fork) + + subject.perform(project.id) + end + + it 'does not do anything in Project#remove_pages method' do + expect(Gitlab::PagesTransfer).not_to receive(:new) + + subject.perform(project.id) + end + end + + context 'project forked from another' do + let!(:parent_project) { create(:empty_project) } + + before do + create(:forked_project_link, forked_to_project: project, forked_from_project: parent_project) + end + + it 'closes open merge requests' do + merge_request = create(:merge_request, source_project: project, target_project: parent_project) + + subject.perform(project.id) + + expect(merge_request.reload).to be_closed + end + + it 'destroys the link' do + subject.perform(project.id) + + expect(parent_project.forked_project_links).to be_empty + end + end + end + end +end diff --git a/spec/workers/pipeline_metrics_worker_spec.rb b/spec/workers/pipeline_metrics_worker_spec.rb index 5dbc0da95c2..ef71125c0b6 100644 --- a/spec/workers/pipeline_metrics_worker_spec.rb +++ b/spec/workers/pipeline_metrics_worker_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe PipelineMetricsWorker do let(:project) { create(:project, :repository) } - let!(:merge_request) { create(:merge_request, source_project: project, source_branch: pipeline.ref) } + let!(:merge_request) { create(:merge_request, source_project: project, source_branch: pipeline.ref, head_pipeline: pipeline) } let(:pipeline) do create(:ci_empty_pipeline, diff --git a/spec/workers/pipeline_schedule_worker_spec.rb b/spec/workers/pipeline_schedule_worker_spec.rb index 91d5a16993f..14ed8b7811e 100644 --- a/spec/workers/pipeline_schedule_worker_spec.rb +++ b/spec/workers/pipeline_schedule_worker_spec.rb @@ -11,40 +11,54 @@ describe PipelineScheduleWorker do end before do - project.add_master(user) - stub_ci_pipeline_to_return_yaml_file - end - context 'when there is a scheduled pipeline within next_run_at' do - let(:next_run_at) { 2.days.ago } + pipeline_schedule.update_column(:next_run_at, 1.day.ago) + end + context 'when the schedule is runnable by the user' do before do - pipeline_schedule.update_column(:next_run_at, next_run_at) + project.add_master(user) end - it 'creates a new pipeline' do - expect { subject }.to change { project.pipelines.count }.by(1) - end + context 'when there is a scheduled pipeline within next_run_at' do + it 'creates a new pipeline' do + expect{ subject }.to change { project.pipelines.count }.by(1) + expect(Ci::Pipeline.last).to be_schedule + end - it 'updates the next_run_at field' do - subject + it 'updates the next_run_at field' do + subject + + expect(pipeline_schedule.reload.next_run_at).to be > Time.now + end - expect(pipeline_schedule.reload.next_run_at).to be > Time.now + it 'sets the schedule on the pipeline' do + subject + + expect(project.pipelines.last.pipeline_schedule).to eq(pipeline_schedule) + end end - it 'sets the schedule on the pipeline' do - subject - expect(project.pipelines.last.pipeline_schedule).to eq(pipeline_schedule) + context 'inactive schedule' do + before do + pipeline_schedule.deactivate! + end + + it 'does not creates a new pipeline' do + expect { subject }.not_to change { project.pipelines.count } + end end end - context 'inactive schedule' do - before do - pipeline_schedule.update(active: false) + context 'when the schedule is not runnable by the user' do + it 'deactivates the schedule' do + subject + + expect(pipeline_schedule.reload.active).to be_falsy end - it 'does not creates a new pipeline' do + it 'does not schedule a pipeline' do expect { subject }.not_to change { project.pipelines.count } end end diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 0260416dbe2..f4bc63bcc6a 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -4,13 +4,16 @@ describe PostReceive do let(:changes) { "123456 789012 refs/heads/tést\n654321 210987 refs/tags/tag" } let(:wrongly_encoded_changes) { changes.encode("ISO-8859-1").force_encoding("UTF-8") } let(:base64_changes) { Base64.encode64(wrongly_encoded_changes) } - let(:project) { create(:project, :repository) } let(:project_identifier) { "project-#{project.id}" } let(:key) { create(:key, user: project.owner) } let(:key_id) { key.shell_id } - context "as a resque worker" do - it "reponds to #perform" do + let(:project) do + create(:project, :repository, auto_cancel_pending_pipelines: 'disabled') + end + + context "as a sidekiq worker" do + it "responds to #perform" do expect(described_class.new).to respond_to(:perform) end end @@ -93,6 +96,27 @@ describe PostReceive do end end + describe '#process_repository_update' do + let(:changes) {'123456 789012 refs/heads/tést'} + let(:fake_hook_data) do + { event_name: 'repository_update' } + end + + before do + allow_any_instance_of(Gitlab::GitPostReceive).to receive(:identify).and_return(project.owner) + allow_any_instance_of(Gitlab::DataBuilder::Repository).to receive(:update).and_return(fake_hook_data) + # silence hooks so we can isolate + allow_any_instance_of(Key).to receive(:post_create_hook).and_return(true) + allow(subject).to receive(:process_project_changes).and_return(true) + end + + it 'calls SystemHooksService' do + expect_any_instance_of(SystemHooksService).to receive(:execute_hooks).with(fake_hook_data, :repository_update_hooks).and_return(true) + + subject.perform(pwd(project), key_id, base64_changes) + end + end + context "webhook" do it "fetches the correct project" do expect(Project).to receive(:find_by).with(id: project.id.to_s) diff --git a/spec/workers/process_commit_worker_spec.rb b/spec/workers/process_commit_worker_spec.rb index 9afe2e610b9..4e036285e8c 100644 --- a/spec/workers/process_commit_worker_spec.rb +++ b/spec/workers/process_commit_worker_spec.rb @@ -31,6 +31,18 @@ describe ProcessCommitWorker do worker.perform(project.id, user.id, commit.to_hash) end + + context 'when commit already exists in upstream project' do + let(:forked) { create(:project, :public) } + + it 'does not process commit message' do + create(:forked_project_link, forked_to_project: forked, forked_from_project: project) + + expect(worker).not_to receive(:process_commit_message) + + worker.perform(forked.id, user.id, forked.commit.to_hash) + end + end end describe '#process_commit_message' do diff --git a/spec/workers/remove_old_web_hook_logs_worker_spec.rb b/spec/workers/remove_old_web_hook_logs_worker_spec.rb new file mode 100644 index 00000000000..6d26ba5dfa0 --- /dev/null +++ b/spec/workers/remove_old_web_hook_logs_worker_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe RemoveOldWebHookLogsWorker do + subject { described_class.new } + + describe '#perform' do + let!(:week_old_record) { create(:web_hook_log, created_at: Time.now - 1.week) } + let!(:three_days_old_record) { create(:web_hook_log, created_at: Time.now - 3.days) } + let!(:one_day_old_record) { create(:web_hook_log, created_at: Time.now - 1.day) } + + it 'removes web hook logs older than 2 days' do + subject.perform + + expect(WebHookLog.all).to include(one_day_old_record) + expect(WebHookLog.all).not_to include(week_old_record, three_days_old_record) + end + end +end diff --git a/spec/workers/repository_check/clear_worker_spec.rb b/spec/workers/repository_check/clear_worker_spec.rb index a3b70c74787..3b1a64c5057 100644 --- a/spec/workers/repository_check/clear_worker_spec.rb +++ b/spec/workers/repository_check/clear_worker_spec.rb @@ -5,7 +5,7 @@ describe RepositoryCheck::ClearWorker do project = create(:empty_project) project.update_columns( last_repository_check_failed: true, - last_repository_check_at: Time.now, + last_repository_check_at: Time.now ) described_class.new.perform diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb index 5e1cb74c7fc..6ea5569b438 100644 --- a/spec/workers/repository_fork_worker_spec.rb +++ b/spec/workers/repository_fork_worker_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe RepositoryForkWorker do - let(:project) { create(:project, :repository) } + let(:project) { create(:project, :repository, :import_scheduled) } let(:fork_project) { create(:project, :repository, forked_from_project: project) } let(:shell) { Gitlab::Shell.new } @@ -46,15 +46,27 @@ describe RepositoryForkWorker do end it "handles bad fork" do + source_path = project.full_path + target_path = fork_project.namespace.full_path + error_message = "Unable to fork project #{project.id} for repository #{source_path} -> #{target_path}" + expect(shell).to receive(:fork_repository).and_return(false) - expect(subject.logger).to receive(:error) + expect do + subject.perform(project.id, '/test/path', source_path, target_path) + end.to raise_error(RepositoryForkWorker::ForkError, error_message) + end - subject.perform( - project.id, - '/test/path', - project.full_path, - fork_project.namespace.full_path) + it 'handles unexpected error' do + source_path = project.full_path + target_path = fork_project.namespace.full_path + + allow_any_instance_of(Gitlab::Shell).to receive(:fork_repository).and_raise(RuntimeError) + + expect do + subject.perform(project.id, '/test/path', source_path, target_path) + end.to raise_error(RepositoryForkWorker::ForkError) + expect(project.reload.import_status).to eq('failed') end end end diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb index 5a2c0671dac..9c277c501f1 100644 --- a/spec/workers/repository_import_worker_spec.rb +++ b/spec/workers/repository_import_worker_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe RepositoryImportWorker do - let(:project) { create(:empty_project) } + let(:project) { create(:empty_project, :import_scheduled) } subject { described_class.new } @@ -21,15 +21,26 @@ describe RepositoryImportWorker do context 'when the import has failed' do it 'hide the credentials that were used in the import URL' do error = %q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found } - expect_any_instance_of(Projects::ImportService).to receive(:execute). - and_return({ status: :error, message: error }) - allow(subject).to receive(:jid).and_return('123') - subject.perform(project.id) + expect_any_instance_of(Projects::ImportService).to receive(:execute).and_return({ status: :error, message: error }) + allow(subject).to receive(:jid).and_return('123') - expect(project.reload.import_error).to include("https://*****:*****@test.com/root/repoC.git/") + expect do + subject.perform(project.id) + end.to raise_error(RepositoryImportWorker::ImportError, error) expect(project.reload.import_jid).not_to be_nil end end + + context 'with unexpected error' do + it 'marks import as failed' do + allow_any_instance_of(Projects::ImportService).to receive(:execute).and_raise(RuntimeError) + + expect do + subject.perform(project.id) + end.to raise_error(RepositoryImportWorker::ImportError) + expect(project.reload.import_status).to eq('failed') + end + end end end diff --git a/tmp/prometheus_multiproc_dir/.gitkeep b/tmp/prometheus_multiproc_dir/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/tmp/prometheus_multiproc_dir/.gitkeep diff --git a/vendor/assets/javascripts/task_list.js b/vendor/assets/javascripts/task_list.js deleted file mode 100644 index 9fbfef03f6d..00000000000 --- a/vendor/assets/javascripts/task_list.js +++ /dev/null @@ -1,258 +0,0 @@ -// 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; }; - - incomplete = "[ ]"; - - 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))); // 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"); - index = 0; - result = (function() { - var i, len, ref, results; - ref = source.split("\n"); - results = []; - for (i = 0, len = ref.length; i < len; i++) { - line = ref[i]; - if (indexOf.call(clean, line) >= 0 && line.match(itemPattern)) { - index += 1; - if (index === itemIndex) { - line = checked ? line.replace(incompletePattern, complete) : line.replace(completePattern, incomplete); - } - } - results.push(line); - } - return results; - })(); - 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'); - $field = $container.find('.js-task-list-field'); - index = 1 + $container.find('.task-list-item-checkbox').index($item); - checked = $item.prop('checked'); - event = $.Event('tasklist:change'); - $field.trigger(event, [index, checked]); - if (!event.isDefaultPrevented()) { - $field.val(updateTaskListItem($field.val(), index, checked)); - $field.trigger('change'); - return $field.trigger('tasklist:changed', [index, checked]); - } - }; - - // 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); - return $container.addClass('is-task-list-enabled').trigger('tasklist:enabled'); - } - }; - - // Enables a collection of TaskList containers. - enableTaskLists = function($containers) { - var container, i, len, results; - results = []; - for (i = 0, len = $containers.length; i < len; i++) { - container = $containers[i]; - results.push(enableTaskList($(container))); - } - 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 = []; - for (i = 0, len = $containers.length; i < len; i++) { - container = $containers[i]; - results.push(disableTaskList($(container))); - } - return results; - }; - - $.fn.taskList = function(method) { - var $container, methods; - $container = $(this).closest('.js-task-list-container'); - methods = { - enable: enableTaskLists, - disable: disableTaskLists - }; - return methods[method || 'enable']($container); - }; - -}).call(this); diff --git a/yarn.lock b/yarn.lock index 8aac2b1b1cd..1db64aead8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25,7 +25,7 @@ acorn-jsx@^3.0.0: dependencies: acorn "^3.0.4" -acorn@4.0.4: +acorn@4.0.4, acorn@^4.0.3: version "4.0.4" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.4.tgz#17a8d6a7a6c4ef538b814ec9abac2779293bf30a" @@ -33,9 +33,9 @@ acorn@^3.0.4: version "3.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" -acorn@^4.0.11, acorn@^4.0.3, acorn@^4.0.4: - version "4.0.11" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.11.tgz#edcda3bd937e7556410d42ed5860f67399c794c0" +acorn@^5.0.0, acorn@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.0.3.tgz#c460df08491463f028ccb82eab3730bf01087b3d" after@0.8.2: version "0.8.2" @@ -1560,10 +1560,20 @@ debug@2.6.0, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0: dependencies: ms "0.7.2" +debug@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.7.tgz#92bad1f6d05bbb6bba22cca88bcd0ec894c2861e" + dependencies: + ms "2.0.0" + decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" +deckar01-task_list@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.0.0.tgz#7f7a595430d21b3036ed5dfbf97d6b65de18e2c9" + deep-extend@~0.4.0: version "0.4.1" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253" @@ -1612,7 +1622,7 @@ delegates@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" -depd@~1.1.0: +depd@1.1.0, depd@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.0.tgz#e1bd82c6aab6ced965b97b88b17ed3e528ca18c3" @@ -1733,7 +1743,7 @@ ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" -ejs@^2.5.5: +ejs@^2.5.6: version "2.5.6" resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.6.tgz#479636bfa3fe3b1debd52087f0acb204b4f19c88" @@ -2081,9 +2091,9 @@ esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" -etag@~1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.7.0.tgz#03d30b5f67dd6e632d2945d30d6652731a34d5d8" +etag@~1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.0.tgz#6f631aef336d6c46362b51764044ce216be3c051" eve-raphael@0.5.0: version "0.5.0" @@ -2166,9 +2176,9 @@ exports-loader@^0.6.4: loader-utils "^1.0.2" source-map "0.5.x" -express@^4.13.3, express@^4.14.1: - version "4.14.1" - resolved "https://registry.yarnpkg.com/express/-/express-4.14.1.tgz#646c237f766f148c2120aff073817b9e4d7e0d33" +express@^4.13.3, express@^4.15.2: + version "4.15.3" + resolved "https://registry.yarnpkg.com/express/-/express-4.15.3.tgz#bab65d0f03aa80c358408972fc700f916944b662" dependencies: accepts "~1.3.3" array-flatten "1.1.1" @@ -2176,26 +2186,28 @@ express@^4.13.3, express@^4.14.1: content-type "~1.0.2" cookie "0.3.1" cookie-signature "1.0.6" - debug "~2.2.0" + debug "2.6.7" depd "~1.1.0" encodeurl "~1.0.1" escape-html "~1.0.3" - etag "~1.7.0" - finalhandler "0.5.1" - fresh "0.3.0" + etag "~1.8.0" + finalhandler "~1.0.3" + fresh "0.5.0" merge-descriptors "1.0.1" methods "~1.1.2" on-finished "~2.3.0" parseurl "~1.3.1" path-to-regexp "0.1.7" - proxy-addr "~1.1.3" - qs "6.2.0" + proxy-addr "~1.1.4" + qs "6.4.0" range-parser "~1.2.0" - send "0.14.2" - serve-static "~1.11.2" - type-is "~1.6.14" + send "0.15.3" + serve-static "1.12.3" + setprototypeof "1.0.3" + statuses "~1.3.1" + type-is "~1.6.15" utils-merge "1.0.0" - vary "~1.1.0" + vary "~1.1.1" extend@^3.0.0, extend@~3.0.0: version "3.0.0" @@ -2287,9 +2299,9 @@ filesize@3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.3.0.tgz#53149ea3460e3b2e024962a51648aa572cf98122" -filesize@^3.5.4: - version "3.5.4" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.5.4.tgz#742fc7fb6aef4ee3878682600c22f840731e1fda" +filesize@^3.5.9: + version "3.5.10" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.5.10.tgz#fc8fa23ddb4ef9e5e0ab6e1e64f679a24a56761f" fill-range@^2.1.0: version "2.2.3" @@ -2311,13 +2323,15 @@ finalhandler@0.5.0: statuses "~1.3.0" unpipe "~1.0.0" -finalhandler@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-0.5.1.tgz#2c400d8d4530935bc232549c5fa385ec07de6fcd" +finalhandler@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.3.tgz#ef47e77950e999780e86022a560e3217e0d0cc89" dependencies: - debug "~2.2.0" + debug "2.6.7" + encodeurl "~1.0.1" escape-html "~1.0.3" on-finished "~2.3.0" + parseurl "~1.3.1" statuses "~1.3.1" unpipe "~1.0.0" @@ -2385,9 +2399,9 @@ forwarded@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" -fresh@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.3.0.tgz#651f838e22424e7566de161d8358caa199f83d4f" +fresh@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.0.tgz#f474ca5e6a9246d6fd8e0953cfa9b9c805afa78e" from@~0: version "0.1.7" @@ -2689,6 +2703,15 @@ http-errors@~1.5.0, http-errors@~1.5.1: setprototypeof "1.0.2" statuses ">= 1.3.1 < 2" +http-errors@~1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.1.tgz#5f8b8ed98aca545656bf572997387f904a722257" + dependencies: + depd "1.1.0" + inherits "2.0.3" + setprototypeof "1.0.3" + statuses ">= 1.3.1 < 2" + http-proxy-middleware@~0.17.4: version "0.17.4" resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833" @@ -2808,9 +2831,9 @@ invert-kv@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" -ipaddr.js@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.2.0.tgz#8aba49c9192799585bdd643e0ccb50e8ae777ba4" +ipaddr.js@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.3.0.tgz#1e03a52fdad83a8bbb2b25cbf4998b4cffcd3dec" is-absolute-url@^2.0.0: version "2.1.0" @@ -3198,7 +3221,7 @@ json3@3.3.2, json3@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" -json5@^0.5.0: +json5@^0.5.0, json5@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" @@ -3643,7 +3666,17 @@ miller-rabin@^4.0.0: version "1.26.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.26.0.tgz#eaffcd0e4fc6935cf8134da246e2e6c35305adff" -mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.13, mime-types@~2.1.7: +mime-db@~1.27.0: + version "1.27.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1" + +mime-types@^2.1.12, mime-types@~2.1.15, mime-types@~2.1.7: + version "2.1.15" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed" + dependencies: + mime-db "~1.27.0" + +mime-types@~2.1.11: version "2.1.14" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.14.tgz#f7ef7d97583fcaf3b7d282b6f8b5679dab1e94ee" dependencies: @@ -3699,10 +3732,18 @@ ms@0.7.2: version "0.7.2" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765" +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + mute-stream@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" +name-all-modules-plugin@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/name-all-modules-plugin/-/name-all-modules-plugin-1.0.1.tgz#0abfb6ad835718b9fb4def0674e06657a954375c" + nan@^2.0.0, nan@^2.3.0: version "2.5.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.1.tgz#d5b01691253326a97a2bbee9e61c55d8d60351e2" @@ -3931,7 +3972,7 @@ onetime@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" -opener@^1.4.2: +opener@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/opener/-/opener-1.4.3.tgz#5c6da2c5d7e5831e8ffa3964950f8d6674ac90b8" @@ -4485,12 +4526,12 @@ proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" -proxy-addr@~1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.3.tgz#dc97502f5722e888467b3fa2297a7b1ff47df074" +proxy-addr@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.4.tgz#27e545f6960a44a627d9b44467e35c1b6b4ce2f3" dependencies: forwarded "~0.1.0" - ipaddr.js "1.2.0" + ipaddr.js "1.3.0" prr@~0.0.0: version "0.0.0" @@ -4532,14 +4573,14 @@ qjobs@^1.1.4: version "1.1.5" resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.1.5.tgz#659de9f2cf8dcc27a1481276f205377272382e73" -qs@6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.0.tgz#3b7848c03c2dece69a9522b0fae8c4126d745f3b" - qs@6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.1.tgz#ce03c5ff0935bc1d9d69a9f14cbd18e568d67625" +qs@6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" + qs@~6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.0.tgz#f403b264f23bc01228c74131b407f18d5ea5d442" @@ -4913,7 +4954,7 @@ rx-lite@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" -safe-buffer@^5.0.1: +safe-buffer@^5.0.1, safe-buffer@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" @@ -4947,20 +4988,20 @@ semver@~4.3.3: version "4.3.6" resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.6.tgz#300bc6e0e86374f7ba61068b5b1ecd57fc6532da" -send@0.14.2: - version "0.14.2" - resolved "https://registry.yarnpkg.com/send/-/send-0.14.2.tgz#39b0438b3f510be5dc6f667a11f71689368cdeef" +send@0.15.3: + version "0.15.3" + resolved "https://registry.yarnpkg.com/send/-/send-0.15.3.tgz#5013f9f99023df50d1bd9892c19e3defd1d53309" dependencies: - debug "~2.2.0" + debug "2.6.7" depd "~1.1.0" destroy "~1.0.4" encodeurl "~1.0.1" escape-html "~1.0.3" - etag "~1.7.0" - fresh "0.3.0" - http-errors "~1.5.1" + etag "~1.8.0" + fresh "0.5.0" + http-errors "~1.6.1" mime "1.3.4" - ms "0.7.2" + ms "2.0.0" on-finished "~2.3.0" range-parser "~1.2.0" statuses "~1.3.1" @@ -4977,14 +5018,14 @@ serve-index@^1.7.2: mime-types "~2.1.11" parseurl "~1.3.1" -serve-static@~1.11.2: - version "1.11.2" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.11.2.tgz#2cf9889bd4435a320cc36895c9aa57bd662e6ac7" +serve-static@1.12.3: + version "1.12.3" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.12.3.tgz#9f4ba19e2f3030c547f8af99107838ec38d5b1e2" dependencies: encodeurl "~1.0.1" escape-html "~1.0.3" parseurl "~1.3.1" - send "0.14.2" + send "0.15.3" set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" @@ -5002,6 +5043,10 @@ setprototypeof@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.2.tgz#81a552141ec104b88e89ce383103ad5c66564d08" +setprototypeof@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" + sha.js@^2.3.6: version "2.4.8" resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.8.tgz#37068c2c476b6baf402d14a49c67f597921f634f" @@ -5496,20 +5541,20 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-is@~1.6.14: - version "1.6.14" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.14.tgz#e219639c17ded1ca0789092dd54a03826b817cb2" +type-is@~1.6.14, type-is@~1.6.15: + version "1.6.15" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410" dependencies: media-typer "0.3.0" - mime-types "~2.1.13" + mime-types "~2.1.15" typedarray@^0.0.6, typedarray@~0.0.5: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" -uglify-js@^2.6, uglify-js@^2.8.5: - version "2.8.21" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.21.tgz#1733f669ae6f82fc90c7b25ec0f5c783ee375314" +uglify-js@^2.6, uglify-js@^2.8.27: + version "2.8.27" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.27.tgz#47787f912b0f242e5b984343be8e35e95f694c9c" dependencies: source-map "~0.5.1" yargs "~3.10.0" @@ -5528,6 +5573,10 @@ ultron@1.0.x: version "1.0.2" resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.0.2.tgz#ace116ab557cd197386a4e88f4685378c8b2e4fa" +ultron@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.0.tgz#b07a2e6a541a815fc6a34ccd4533baec307ca864" + unc-path-regex@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" @@ -5640,9 +5689,9 @@ validate-npm-package-license@^3.0.1: spdx-correct "~1.0.0" spdx-expression-parse "~1.0.0" -vary@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.0.tgz#e1e5affbbd16ae768dd2674394b9ad3022653140" +vary@~1.1.0, vary@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37" vendors@^1.0.0: version "1.0.1" @@ -5729,20 +5778,21 @@ wbuf@^1.1.0, wbuf@^1.4.0: dependencies: minimalistic-assert "^1.0.0" -webpack-bundle-analyzer@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-2.3.0.tgz#0d05e96a43033f7cc57f6855b725782ba61e93a4" +webpack-bundle-analyzer@^2.8.2: + version "2.8.2" + resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-2.8.2.tgz#8b6240c29a9d63bc72f09d920fb050adbcce9fe8" dependencies: - acorn "^4.0.11" + acorn "^5.0.3" chalk "^1.1.3" commander "^2.9.0" - ejs "^2.5.5" - express "^4.14.1" - filesize "^3.5.4" + ejs "^2.5.6" + express "^4.15.2" + filesize "^3.5.9" gzip-size "^3.0.0" lodash "^4.17.4" mkdirp "^0.5.1" - opener "^1.4.2" + opener "^1.4.3" + ws "^2.3.1" webpack-dev-middleware@^1.0.11, webpack-dev-middleware@^1.9.0: version "1.10.0" @@ -5789,11 +5839,11 @@ webpack-sources@^0.2.3: source-list-map "^1.1.1" source-map "~0.5.3" -webpack@^2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-2.3.3.tgz#eecc083c18fb7bf958ea4f40b57a6640c5a0cc78" +webpack@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-2.6.1.tgz#2e0457f0abb1ac5df3ab106c69c672f236785f07" dependencies: - acorn "^4.0.4" + acorn "^5.0.0" acorn-dynamic-import "^2.0.0" ajv "^4.7.0" ajv-keywords "^1.1.1" @@ -5801,6 +5851,7 @@ webpack@^2.3.3: enhanced-resolve "^3.0.0" interpret "^1.0.0" json-loader "^0.5.4" + json5 "^0.5.1" loader-runner "^2.3.0" loader-utils "^0.2.16" memory-fs "~0.4.1" @@ -5809,7 +5860,7 @@ webpack@^2.3.3: source-map "^0.5.3" supports-color "^3.1.0" tapable "~0.2.5" - uglify-js "^2.8.5" + uglify-js "^2.8.27" watchpack "^1.3.1" webpack-sources "^0.2.3" yargs "^6.0.0" @@ -5898,6 +5949,13 @@ ws@1.1.1: options ">=0.0.5" ultron "1.0.x" +ws@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-2.3.1.tgz#6b94b3e447cb6a363f785eaf94af6359e8e81c80" + dependencies: + safe-buffer "~5.0.1" + ultron "~1.1.0" + wtf-8@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wtf-8/-/wtf-8-1.0.0.tgz#392d8ba2d0f1c34d1ee2d630f15d0efb68e1048a" |