diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2018-04-04 16:39:53 +0300 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2018-04-04 16:39:53 +0300 |
commit | 35dd0b6dfbbac5ce6b407340b5f64519e9cb6c42 (patch) | |
tree | 8fcab294e12df3e938deb926bf8e330464bfdbaf | |
parent | ff1383fb11267996bf040260513e6d3d8f468def (diff) | |
parent | 873a1d9364337b4de4665032a5841a162706d1d4 (diff) |
[ci skip] Merge branch 'master' into 42568-pipeline-empty-state
* master: (293 commits)
Revert changelog entry for removed feature
Revert "Allow CI/CD Jobs being grouped on version strings"
Resolve "Protected branches count is wrong when a wildcard includes several protected branches"
Use standard codequality job
Resolve "Allow the configuration of a project's merge method via the API"
[Rails5] Rename `sort` methods to `sort_by_attribute`
Add better LDAP connection handling
Updated components to PascalCase
Handle invalid params when trying update_username
Move network related app settings to expandable blocks
[Rails5] Update Gemfile.rails5.lock [ci skip]
Update Security Products examples documentation
Backport Gitlab::Git::Checksum to CE
Add changelog
Refactor discussions/notes code
Remove unnecessary section looking in admin settings qa
Explicitly use page context for qa/factory/settings/hashed_storage.rb
Use gitlab_environment because we need:
Allow feature gate removal through the API
Use shard name in Git::GitlabProjects instead of shard path
...
543 files changed, 11048 insertions, 4573 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 70f41e4dc98..86bdb7a4643 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -264,8 +264,18 @@ package-and-qa: stage: build cache: {} when: manual + variables: + GIT_STRATEGY: none + retry: 0 + before_script: + # We need to download the script rather than clone the repo since the + # package-and-qa job will not be able to run when the branch gets + # deleted (when merging the MR). + - apk add --update openssl + - wget https://gitlab.com/$CI_PROJECT_PATH/raw/$CI_COMMIT_SHA/scripts/trigger-build-omnibus + - chmod 755 trigger-build-omnibus script: - - scripts/trigger-build-omnibus + - ./trigger-build-omnibus only: - //@gitlab-org/gitlab-ce - //@gitlab-org/gitlab-ee @@ -608,21 +618,26 @@ karma: codequality: <<: *dedicated-no-docs-no-db-pull-cache-job - image: docker:latest + image: docker:stable + allow_failure: true + # gitlab-org runners set `privileged: false` but we need to have it set to true + # since we're using Docker in Docker + tags: [] before_script: [] services: - docker:dind variables: SETUP_DB: "false" DOCKER_DRIVER: overlay2 - CODECLIMATE_FORMAT: json cache: {} dependencies: [] script: - - apk update && apk add jq - - ./scripts/codequality analyze -f json > raw_codeclimate.json || true - # The following line keeps only the fields used in the MR widget, reducing the JSON artifact size - - jq -c 'map({check_name,description,fingerprint,location})' raw_codeclimate.json > codeclimate.json + # Get the custom rubocop codeclimate image (https://gitlab.com/gitlab-org/codeclimate-rubocop/wikis/home) + - docker pull dev.gitlab.org:5005/gitlab/gitlab-build-images:gitlab-codeclimate-rubocop-0-52-1 + - docker tag dev.gitlab.org:5005/gitlab/gitlab-build-images:gitlab-codeclimate-rubocop-0-52-1 codeclimate/codeclimate-rubocop:gitlab-codeclimate-rubocop-0-52-1 + # Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Security Products + - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') + - docker run --env SOURCE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code artifacts: paths: [codeclimate.json] expire_in: 1 week diff --git a/.gitlab/merge_request_templates/Database Changes.md b/.gitlab/merge_request_templates/Database Changes.md index 8302b3b30c7..68bc0fd1c7f 100644 --- a/.gitlab/merge_request_templates/Database Changes.md +++ b/.gitlab/merge_request_templates/Database Changes.md @@ -33,13 +33,16 @@ When removing columns, tables, indexes or other structures: ## General Checklist -- [ ] [Changelog entry](https://docs.gitlab.com/ce/development/changelog.html) added, if necessary -- [ ] [Documentation created/updated](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/doc_styleguide.md) +- [ ] [Changelog entry](https://docs.gitlab.com/ee/development/changelog.html) added, if necessary +- [ ] [Documentation created/updated](https://docs.gitlab.com/ee/development/doc_styleguide.html) - [ ] API support added - [ ] Tests added for this feature/bug - Review - [ ] Has been reviewed by Backend - [ ] Has been reviewed by Database -- [ ] Conform by the [merge request performance guides](http://docs.gitlab.com/ce/development/merge_request_performance_guidelines.html) -- [ ] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#style-guides) +- [ ] Conform by the [merge request performance guides](https://docs.gitlab.com/ee/development/merge_request_performance_guidelines.html) +- [ ] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ee/blob/master/CONTRIBUTING.md#style-guides) - [ ] [Squashed related commits together](https://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits) +- [ ] Internationalization required/considered +- [ ] If paid feature, have we considered GitLab.com plan and how it works for groups and is there a design for promoting it to users who aren't on the correct plan +- [ ] End-to-end tests pass (`package-qa` manual pipeline job) diff --git a/CHANGELOG.md b/CHANGELOG.md index adb0ec9f5b1..6491905a1ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 10.6.2 (2018-03-29) + +### Fixed (2 changes, 1 of them is from the community) + +- Don't capture trailing punctuation when autolinking. !17965 +- Cloning a repository over HTTPS with LDAP credentials causes a HTTP 401 Access denied. (Horatiu Eugen Vlad) + + ## 10.6.1 (2018-03-27) ### Security (1 change) @@ -183,7 +191,6 @@ entry. - Enable privileged mode for GitLab Runner. !17528 - Expose GITLAB_FEATURES as CI/CD variable (fixes #40994). - Upgrade GitLab Workhorse to 4.0.0. -- Allow CI/CD Jobs being grouped on version strings. - Add discussions API for Issues and Snippets. - Add one group board to Libre. - Add support for filtering by source and target branch to merge requests API. diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 36545ad338e..9188543ea64 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.92.0 +0.93.0 @@ -6,7 +6,6 @@ end gem_versions = {} gem_versions['activerecord_sane_schema_dumper'] = rails5? ? '1.0' : '0.2' gem_versions['default_value_for'] = rails5? ? '~> 3.0.5' : '~> 3.0.0' -gem_versions['html-pipeline'] = rails5? ? '~> 2.6.0' : '~> 1.11.0' gem_versions['rails'] = rails5? ? '5.0.6' : '4.2.10' gem_versions['rails-i18n'] = rails5? ? '~> 5.1' : '~> 4.0.9' # --- The end of special code for migrating to Rails 5.0 --- @@ -28,7 +27,7 @@ gem 'default_value_for', gem_versions['default_value_for'] gem 'mysql2', '~> 0.4.10', group: :mysql gem 'pg', '~> 0.18.2', group: :postgres -gem 'rugged', '~> 0.26.0' +gem 'rugged', '~> 0.27' gem 'grape-route-helpers', '~> 2.1.0' gem 'faraday', '~> 0.12' @@ -44,7 +43,7 @@ gem 'omniauth-cas3', '~> 1.1.4' gem 'omniauth-facebook', '~> 4.0.0' gem 'omniauth-github', '~> 1.1.1' gem 'omniauth-gitlab', '~> 1.0.2' -gem 'omniauth-google-oauth2', '~> 0.5.2' +gem 'omniauth-google-oauth2', '~> 0.5.3' gem 'omniauth-kerberos', '~> 0.3.0', group: :kerberos gem 'omniauth-oauth2-generic', '~> 0.2.2' gem 'omniauth-saml', '~> 1.10' @@ -136,7 +135,7 @@ gem 'unf', '~> 0.1.4' gem 'seed-fu', '~> 2.3.7' # Markdown and HTML processing -gem 'html-pipeline', gem_versions['html-pipeline'] +gem 'html-pipeline', '~> 2.7.1' gem 'deckar01-task_list', '2.0.0' gem 'gitlab-markup', '~> 1.6.2' gem 'redcarpet', '~> 3.4' @@ -310,7 +309,7 @@ end group :development do gem 'foreman', '~> 0.84.0' - gem 'brakeman', '~> 3.6.0', require: false + gem 'brakeman', '~> 4.2', require: false gem 'letter_opener_web', '~> 1.3.0' gem 'rblineprof', '~> 0.3.6', platform: :mri, require: false @@ -376,6 +375,8 @@ group :development, :test do gem 'stackprof', '~> 0.2.10', require: false gem 'simple_po_parser', '~> 1.1.2', require: false + + gem 'timecop', '~> 0.8.0' end group :test do @@ -383,9 +384,8 @@ group :test do gem 'email_spec', '~> 1.6.0' gem 'json-schema', '~> 2.8.0' gem 'webmock', '~> 2.3.2' - gem 'test_after_commit', '~> 1.1' + gem 'test_after_commit', '~> 1.1' unless rails5? # Remove this gem when migrated to rails 5.0. It's been integrated to rails 5.0. gem 'sham_rack', '~> 1.3.6' - gem 'timecop', '~> 0.8.0' gem 'concurrent-ruby', '~> 1.0.5' gem 'test-prof', '~> 0.2.5' end diff --git a/Gemfile.lock b/Gemfile.lock index 5b61f3dbf67..55e7bd9492a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -95,7 +95,7 @@ GEM autoprefixer-rails (>= 5.2.1) sass (>= 3.3.4) bootstrap_form (2.7.0) - brakeman (3.6.1) + brakeman (4.2.1) browser (2.2.0) builder (3.2.3) bullet (5.5.1) @@ -120,7 +120,7 @@ GEM activesupport (>= 4.0.0) mime-types (>= 1.16) cause (0.1) - charlock_holmes (0.7.5) + charlock_holmes (0.7.6) childprocess (0.7.0) ffi (~> 1.0, >= 1.0.11) chronic (0.10.2) @@ -399,9 +399,9 @@ GEM hipchat (1.5.2) httparty mimemagic - html-pipeline (1.11.0) + html-pipeline (2.7.1) activesupport (>= 2) - nokogiri (~> 1.4) + nokogiri (>= 1.4) html2text (0.2.0) nokogiri (~> 1.6) htmlentities (4.3.4) @@ -550,11 +550,10 @@ GEM omniauth-gitlab (1.0.2) omniauth (~> 1.0) omniauth-oauth2 (~> 1.0) - omniauth-google-oauth2 (0.5.2) - jwt (~> 1.5) - multi_json (~> 1.3) + omniauth-google-oauth2 (0.5.3) + jwt (>= 1.5) omniauth (>= 1.1.1) - omniauth-oauth2 (>= 1.3.1) + omniauth-oauth2 (>= 1.5) omniauth-jwt (0.0.2) jwt omniauth (~> 1.1) @@ -566,8 +565,8 @@ GEM omniauth-oauth (1.1.0) oauth omniauth (~> 1.0) - omniauth-oauth2 (1.4.0) - oauth2 (~> 1.0) + omniauth-oauth2 (1.5.0) + oauth2 (~> 1.1) omniauth (~> 1.2) omniauth-oauth2-generic (0.2.2) omniauth-oauth2 (~> 1.0) @@ -814,7 +813,7 @@ GEM rubyzip (1.2.1) rufus-scheduler (3.4.0) et-orbi (~> 1.0) - rugged (0.26.0) + rugged (0.27.0) safe_yaml (1.0.4) sanitize (2.1.0) nokogiri (>= 1.4.4) @@ -1013,7 +1012,7 @@ DEPENDENCIES binding_of_caller (~> 0.7.2) bootstrap-sass (~> 3.3.0) bootstrap_form (~> 2.7.0) - brakeman (~> 3.6.0) + brakeman (~> 4.2) browser (~> 2.2) bullet (~> 5.5.0) bundler-audit (~> 0.5.0) @@ -1084,7 +1083,7 @@ DEPENDENCIES hashie-forbidden_attributes health_check (~> 2.6.0) hipchat (~> 1.5.0) - html-pipeline (~> 1.11.0) + html-pipeline (~> 2.7.1) html2text httparty (~> 0.13.3) influxdb (~> 0.2) @@ -1118,7 +1117,7 @@ DEPENDENCIES omniauth-facebook (~> 4.0.0) omniauth-github (~> 1.1.1) omniauth-gitlab (~> 1.0.2) - omniauth-google-oauth2 (~> 0.5.2) + omniauth-google-oauth2 (~> 0.5.3) omniauth-jwt (~> 0.0.2) omniauth-kerberos (~> 0.3.0) omniauth-oauth2-generic (~> 0.2.2) @@ -1174,7 +1173,7 @@ DEPENDENCIES ruby-prof (~> 0.17.0) ruby_parser (~> 3.8) rufus-scheduler (~> 3.4) - rugged (~> 0.26.0) + rugged (~> 0.27) sanitize (~> 2.0) sass-rails (~> 5.0.6) scss_lint (~> 0.56.0) diff --git a/Gemfile.rails5.lock b/Gemfile.rails5.lock index 86a22dbe550..08ae3fb514c 100644 --- a/Gemfile.rails5.lock +++ b/Gemfile.rails5.lock @@ -60,7 +60,7 @@ GEM faraday_middleware-multi_json (~> 0.0) oauth2 (~> 1.0) asciidoctor (1.5.6.1) - asciidoctor-plantuml (0.0.7) + asciidoctor-plantuml (0.0.8) asciidoctor (~> 1.5) asset_sync (2.2.0) activemodel (>= 4.1.0) @@ -97,7 +97,7 @@ GEM autoprefixer-rails (>= 5.2.1) sass (>= 3.3.4) bootstrap_form (2.7.0) - brakeman (3.6.2) + brakeman (4.2.1) browser (2.5.3) builder (3.2.3) bullet (5.5.1) @@ -144,6 +144,7 @@ GEM connection_pool (2.2.1) crack (0.4.3) safe_yaml (~> 1.0.0) + crass (1.0.3) creole (0.5.0) css_parser (1.6.0) addressable @@ -244,10 +245,11 @@ GEM builder excon (~> 0.58) formatador (~> 0.2) - fog-google (0.6.0) + fog-google (1.3.3) fog-core fog-json fog-xml + google-api-client (~> 0.19.1) fog-json (1.0.2) fog-core (~> 1.0) multi_json (~> 1.10) @@ -289,7 +291,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly-proto (0.88.0) + gitaly-proto (0.91.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (5.3.3) @@ -398,7 +400,7 @@ GEM hipchat (1.5.4) httparty mimemagic - html-pipeline (2.6.0) + html-pipeline (2.7.1) activesupport (>= 2) nokogiri (>= 1.4) html2text (0.2.1) @@ -484,7 +486,8 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.0.3) + loofah (2.2.2) + crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.0) mini_mime (>= 0.1.1) @@ -527,8 +530,8 @@ GEM omniauth (1.8.1) hashie (>= 3.4.6, < 3.6.0) rack (>= 1.6.2, < 3) - omniauth-auth0 (1.4.2) - omniauth-oauth2 (~> 1.1) + omniauth-auth0 (2.0.0) + omniauth-oauth2 (~> 1.4) omniauth-authentiq (0.3.1) omniauth-oauth2 (~> 1.3, >= 1.3.1) omniauth-azure-oauth2 (0.0.9) @@ -551,6 +554,9 @@ GEM jwt (>= 1.5) omniauth (>= 1.1.1) omniauth-oauth2 (>= 1.5) + omniauth-jwt (0.0.2) + jwt + omniauth (~> 1.1) omniauth-kerberos (0.3.0) omniauth-multipassword timfel-krb5-auth (~> 0.8) @@ -569,9 +575,9 @@ GEM ruby-saml (~> 1.7) omniauth-shibboleth (1.2.1) omniauth (>= 1.0.0) - omniauth-twitter (1.2.1) - json (~> 1.3) + omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) + rack omniauth_crowd (2.2.3) activesupport nokogiri (>= 1.4.4) @@ -808,7 +814,7 @@ GEM rubyzip (1.2.1) rufus-scheduler (3.4.2) et-orbi (~> 1.0) - rugged (0.26.0) + rugged (0.27.0) safe_yaml (1.0.4) sanitize (2.1.0) nokogiri (>= 1.4.4) @@ -907,8 +913,6 @@ GEM sysexits (1.2.0) temple (0.7.7) test-prof (0.2.5) - test_after_commit (1.1.0) - activerecord (>= 3.2) text (1.3.1) thin (1.7.2) daemons (~> 1.0, >= 1.0.9) @@ -995,8 +999,8 @@ DEPENDENCIES akismet (~> 2.0) allocations (~> 1.0) asana (~> 0.6.0) - asciidoctor (~> 1.5.2) - asciidoctor-plantuml (= 0.0.7) + asciidoctor (~> 1.5.6) + asciidoctor-plantuml (= 0.0.8) asset_sync (~> 2.2.0) attr_encrypted (~> 3.0.0) awesome_print (~> 1.2.0) @@ -1009,7 +1013,7 @@ DEPENDENCIES binding_of_caller (~> 0.7.2) bootstrap-sass (~> 3.3.0) bootstrap_form (~> 2.7.0) - brakeman (~> 3.6.0) + brakeman (~> 4.2) browser (~> 2.2) bullet (~> 5.5.0) bundler-audit (~> 0.5.0) @@ -1044,9 +1048,9 @@ DEPENDENCIES flipper-active_record (~> 0.13.0) flipper-active_support_cache_store (~> 0.13.0) fog-aliyun (~> 0.2.0) - fog-aws (~> 2.0) + fog-aws (~> 2.0.1) fog-core (~> 1.44) - fog-google (~> 0.5) + fog-google (~> 1.3.3) fog-local (~> 0.3) fog-openstack (~> 0.1) fog-rackspace (~> 0.1.1) @@ -1058,7 +1062,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 0.88.0) + gitaly-proto (~> 0.91.0) github-linguist (~> 5.3.3) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.6.2) @@ -1080,7 +1084,7 @@ DEPENDENCIES hashie-forbidden_attributes health_check (~> 2.6.0) hipchat (~> 1.5.0) - html-pipeline (~> 2.6.0) + html-pipeline (~> 2.7.1) html2text httparty (~> 0.13.3) influxdb (~> 0.2) @@ -1095,7 +1099,7 @@ DEPENDENCIES license_finder (~> 3.1) licensee (~> 8.9) lograge (~> 0.5) - loofah (~> 2.0.3) + loofah (~> 2.2) mail_room (~> 0.9.1) method_source (~> 0.8) minitest (~> 5.7.0) @@ -1107,19 +1111,20 @@ DEPENDENCIES oauth2 (~> 1.4) octokit (~> 4.8) omniauth (~> 1.8) - omniauth-auth0 (~> 1.4.1) + omniauth-auth0 (~> 2.0.0) omniauth-authentiq (~> 0.3.1) omniauth-azure-oauth2 (~> 0.0.9) omniauth-cas3 (~> 1.1.4) omniauth-facebook (~> 4.0.0) omniauth-github (~> 1.1.1) omniauth-gitlab (~> 1.0.2) - omniauth-google-oauth2 (~> 0.5.2) + omniauth-google-oauth2 (~> 0.5.3) + omniauth-jwt (~> 0.0.2) omniauth-kerberos (~> 0.3.0) omniauth-oauth2-generic (~> 0.2.2) omniauth-saml (~> 1.10) omniauth-shibboleth (~> 1.2.0) - omniauth-twitter (~> 1.2.0) + omniauth-twitter (~> 1.4) omniauth_crowd (~> 2.2.0) org-ruby (~> 0.9.12) peek (~> 1.0.1) @@ -1169,7 +1174,7 @@ DEPENDENCIES ruby-prof (~> 0.17.0) ruby_parser (~> 3.8) rufus-scheduler (~> 3.4) - rugged (~> 0.26.0) + rugged (~> 0.27) sanitize (~> 2.0) sass-rails (~> 5.0.6) scss_lint (~> 0.56.0) @@ -1197,7 +1202,6 @@ DEPENDENCIES state_machines-activerecord (~> 0.5.1) sys-filesystem (~> 1.1.6) test-prof (~> 0.2.5) - test_after_commit (~> 1.1) thin (~> 1.7.0) timecop (~> 0.8.0) toml-rb (~> 1.0.0) diff --git a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico b/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico Binary files differnew file mode 100644 index 00000000000..48b1095370d --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_created.ico b/app/assets/images/ci_favicons/canary/favicon_status_created.ico Binary files differnew file mode 100644 index 00000000000..623c728faf6 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_created.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico b/app/assets/images/ci_favicons/canary/favicon_status_failed.ico Binary files differnew file mode 100644 index 00000000000..3073fe5a761 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_failed.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico b/app/assets/images/ci_favicons/canary/favicon_status_manual.ico Binary files differnew file mode 100644 index 00000000000..6c713d7b675 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_manual.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico b/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico Binary files differnew file mode 100644 index 00000000000..dbf855fdafd --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico b/app/assets/images/ci_favicons/canary/favicon_status_pending.ico Binary files differnew file mode 100644 index 00000000000..ccd00606aeb --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_pending.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_running.ico b/app/assets/images/ci_favicons/canary/favicon_status_running.ico Binary files differnew file mode 100644 index 00000000000..968e7c4c2d4 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_running.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico b/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico Binary files differnew file mode 100644 index 00000000000..7e3be35cc3a --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_success.ico b/app/assets/images/ci_favicons/canary/favicon_status_success.ico Binary files differnew file mode 100644 index 00000000000..a1fb6e91d65 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_success.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico b/app/assets/images/ci_favicons/canary/favicon_status_warning.ico Binary files differnew file mode 100644 index 00000000000..5d931619fb2 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_warning.ico diff --git a/app/assets/images/favicon-yellow.ico b/app/assets/images/favicon-yellow.ico Binary files differnew file mode 100644 index 00000000000..b650f277fb6 --- /dev/null +++ b/app/assets/images/favicon-yellow.ico diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index cbcefb2c18f..8ad3d18b302 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -10,6 +10,9 @@ const Api = { projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', projectLabelsPath: '/:namespace_path/:project_path/labels', + mergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', + mergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', + mergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', groupLabelsPath: '/groups/:namespace_path/-/labels', licensePath: '/api/:version/templates/licenses/:key', gitignorePath: '/api/:version/templates/gitignores/:key', @@ -22,25 +25,27 @@ const Api = { createBranchPath: '/api/:version/projects/:id/repository/branches', group(groupId, callback) { - const url = Api.buildUrl(Api.groupPath) - .replace(':id', groupId); - return axios.get(url) - .then(({ data }) => { - callback(data); + const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); + return axios.get(url).then(({ data }) => { + callback(data); - return data; - }); + return data; + }); }, // Return groups list. Filtered by query groups(query, options, callback = $.noop) { const url = Api.buildUrl(Api.groupsPath); - return axios.get(url, { - params: Object.assign({ - search: query, - per_page: 20, - }, options), - }) + return axios + .get(url, { + params: Object.assign( + { + search: query, + per_page: 20, + }, + options, + ), + }) .then(({ data }) => { callback(data); @@ -51,12 +56,13 @@ const Api = { // Return namespaces list. Filtered by query namespaces(query, callback) { const url = Api.buildUrl(Api.namespacesPath); - return axios.get(url, { - params: { - search: query, - per_page: 20, - }, - }) + return axios + .get(url, { + params: { + search: query, + per_page: 20, + }, + }) .then(({ data }) => callback(data)); }, @@ -73,9 +79,10 @@ const Api = { defaults.membership = true; } - return axios.get(url, { - params: Object.assign(defaults, options), - }) + return axios + .get(url, { + params: Object.assign(defaults, options), + }) .then(({ data }) => { callback(data); @@ -85,8 +92,32 @@ const Api = { // Return single project project(projectPath) { - const url = Api.buildUrl(Api.projectPath) - .replace(':id', encodeURIComponent(projectPath)); + const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath)); + + return axios.get(url); + }, + + // Return Merge Request for project + mergeRequest(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); + + return axios.get(url); + }, + + mergeRequestChanges(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestChangesPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); + + return axios.get(url); + }, + + mergeRequestVersions(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestVersionsPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); return axios.get(url); }, @@ -102,30 +133,30 @@ const Api = { url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath); } - return axios.post(url, { - label: data, - }) + return axios + .post(url, { + label: data, + }) .then(res => callback(res.data)) .catch(e => callback(e.response.data)); }, // Return group projects list. Filtered by query groupProjects(groupId, query, callback) { - const url = Api.buildUrl(Api.groupProjectsPath) - .replace(':id', groupId); - return axios.get(url, { - params: { - search: query, - per_page: 20, - }, - }) + const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); + return axios + .get(url, { + params: { + search: query, + per_page: 20, + }, + }) .then(({ data }) => callback(data)); }, commitMultiple(id, data) { // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions - const url = Api.buildUrl(Api.commitPath) - .replace(':id', encodeURIComponent(id)); + const url = Api.buildUrl(Api.commitPath).replace(':id', encodeURIComponent(id)); return axios.post(url, JSON.stringify(data), { headers: { 'Content-Type': 'application/json; charset=utf-8', @@ -136,39 +167,34 @@ const Api = { branchSingle(id, branch) { const url = Api.buildUrl(Api.branchSinglePath) .replace(':id', encodeURIComponent(id)) - .replace(':branch', branch); + .replace(':branch', encodeURIComponent(branch)); return axios.get(url); }, // Return text for a specific license licenseText(key, data, callback) { - const url = Api.buildUrl(Api.licensePath) - .replace(':key', key); - return axios.get(url, { - params: data, - }) + const url = Api.buildUrl(Api.licensePath).replace(':key', key); + return axios + .get(url, { + params: data, + }) .then(res => callback(res.data)); }, gitignoreText(key, callback) { - const url = Api.buildUrl(Api.gitignorePath) - .replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + const url = Api.buildUrl(Api.gitignorePath).replace(':key', key); + return axios.get(url).then(({ data }) => callback(data)); }, gitlabCiYml(key, callback) { - const url = Api.buildUrl(Api.gitlabCiYmlPath) - .replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + const url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); + return axios.get(url).then(({ data }) => callback(data)); }, dockerfileYml(key, callback) { const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + return axios.get(url).then(({ data }) => callback(data)); }, issueTemplate(namespacePath, projectPath, key, type, callback) { @@ -177,7 +203,8 @@ const Api = { .replace(':type', type) .replace(':project_path', projectPath) .replace(':namespace_path', namespacePath); - return axios.get(url) + return axios + .get(url) .then(({ data }) => callback(null, data)) .catch(callback); }, @@ -185,10 +212,13 @@ const Api = { users(query, options) { const url = Api.buildUrl(this.usersPath); return axios.get(url, { - params: Object.assign({ - search: query, - per_page: 20, - }, options), + params: Object.assign( + { + search: query, + per_page: 20, + }, + options, + ), }); }, diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 6da33a26e58..0e1ca7fe883 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import _ from 'underscore'; import Cookies from 'js-cookie'; import { __ } from './locale'; -import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; +import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -300,7 +300,7 @@ class AwardsHandler { } isInVueNoteablePage() { - return isInIssuePage() || this.isVueMRDiscussions(); + return isInIssuePage() || isInEpicPage() || this.isVueMRDiscussions(); } getVotesBlock() { diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index 7dcf1aeed17..eb4e59d12b1 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -31,7 +31,7 @@ export default function renderMath($els) { if (!$els.length) return; Promise.all([ import(/* webpackChunkName: 'katex' */ 'katex'), - import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.css'), + import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.min.css'), ]).then(([katex]) => { renderWithKaTeX($els, katex); }).catch(() => flash(__('An error occurred while rendering KaTeX'))); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 8259133c95b..7e9770a9ea2 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -54,6 +54,7 @@ class GfmAutoComplete { alias: 'commands', searchKey: 'search', skipSpecialCharacterTest: true, + skipMarkdownCharacterTest: true, data: GfmAutoComplete.defaultLoadingData, displayTpl(value) { if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template; @@ -376,15 +377,23 @@ class GfmAutoComplete { return $.fn.atwho.default.callbacks.filter(query, data, searchKey); }, beforeInsert(value) { - let resultantValue = value; + let withoutAt = value.substring(1); + const at = value.charAt(); + if (value && !this.setting.skipSpecialCharacterTest) { - const withoutAt = value.substring(1); - const regex = value.charAt() === '~' ? /\W|^\d+$/ : /\W/; + const regex = at === '~' ? /\W|^\d+$/ : /\W/; if (withoutAt && regex.test(withoutAt)) { - resultantValue = `${value.charAt()}"${withoutAt}"`; + withoutAt = `"${withoutAt}"`; } } - return resultantValue; + + // We can ignore this for quick actions because they are processed + // before Markdown. + if (!this.setting.skipMarkdownCharacterTest) { + withoutAt = withoutAt.replace(/([~\-_*`])/g, '\\$&'); + } + + return `${at}${withoutAt}`; }, matcher(flag, subtext) { const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue index 0c54c992e51..037e3efb4ce 100644 --- a/app/assets/javascripts/ide/components/changed_file_icon.vue +++ b/app/assets/javascripts/ide/components/changed_file_icon.vue @@ -1,25 +1,25 @@ <script> - import icon from '~/vue_shared/components/icon.vue'; +import icon from '~/vue_shared/components/icon.vue'; - export default { - components: { - icon, +export default { + components: { + icon, + }, + props: { + file: { + type: Object, + required: true, }, - props: { - file: { - type: Object, - required: true, - }, + }, + computed: { + changedIcon() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; }, - computed: { - changedIcon() { - return this.file.tempFile ? 'file-addition' : 'file-modified'; - }, - changedIconClass() { - return `multi-${this.changedIcon}`; - }, + changedIconClass() { + return `multi-${this.changedIcon}`; }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 18934af004a..560cdd941cd 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -1,38 +1,36 @@ <script> - import { mapActions } from 'vuex'; - import icon from '~/vue_shared/components/icon.vue'; - import router from '../../ide_router'; +import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; - export default { - components: { - icon, +export default { + components: { + Icon, + }, + props: { + file: { + type: Object, + required: true, }, - props: { - file: { - type: Object, - required: true, - }, + }, + computed: { + iconName() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; }, - computed: { - iconName() { - return this.file.tempFile ? 'file-addition' : 'file-modified'; - }, - iconClass() { - return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; - }, + iconClass() { + return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; }, - methods: { - ...mapActions([ - 'discardFileChanges', - 'updateViewer', - ]), - openFileInEditor(file) { - this.updateViewer('diff'); - - router.push(`/project${file.url}`); - }, + }, + methods: { + ...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']), + openFileInEditor(file) { + return this.openPendingTab(file).then(changeViewer => { + if (changeViewer) { + this.updateViewer('diff'); + } + }); }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index 170347881e0..0c44a755f56 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -1,31 +1,44 @@ <script> - import Icon from '~/vue_shared/components/icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __, sprintf } from '~/locale'; - export default { - components: { - Icon, +export default { + components: { + Icon, + }, + props: { + hasChanges: { + type: Boolean, + required: false, + default: false, }, - props: { - hasChanges: { - type: Boolean, - required: false, - default: false, - }, - viewer: { - type: String, - required: true, - }, - showShadow: { - type: Boolean, - required: true, - }, + mergeRequestId: { + type: String, + required: false, + default: '', }, - methods: { - changeMode(mode) { - this.$emit('click', mode); - }, + viewer: { + type: String, + required: true, }, - }; + showShadow: { + type: Boolean, + required: true, + }, + }, + computed: { + mergeReviewLine() { + return sprintf(__('Reviewing (merge request !%{mergeRequestId})'), { + mergeRequestId: this.mergeRequestId, + }); + }, + }, + methods: { + changeMode(mode) { + this.$emit('click', mode); + }, + }, +}; </script> <template> @@ -43,7 +56,10 @@ }" data-toggle="dropdown" > - <template v-if="viewer === 'editor'"> + <template v-if="viewer === 'mrdiff' && mergeRequestId"> + {{ mergeReviewLine }} + </template> + <template v-else-if="viewer === 'editor'"> {{ __('Editing') }} </template> <template v-else> @@ -57,6 +73,29 @@ </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> <ul> + <template v-if="mergeRequestId"> + <li> + <a + href="#" + @click.prevent="changeMode('mrdiff')" + :class="{ + 'is-active': viewer === 'mrdiff', + }" + > + <strong class="dropdown-menu-inner-title"> + {{ mergeReviewLine }} + </strong> + <span class="dropdown-menu-inner-content"> + {{ __('Compare changes with the merge request target branch') }} + </span> + </a> + </li> + <li + role="separator" + class="divider" + > + </li> + </template> <li> <a href="#" diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 015e750525a..d22869466c9 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,51 +1,51 @@ <script> - import { mapState, mapGetters } from 'vuex'; - import ideSidebar from './ide_side_bar.vue'; - import ideContextbar from './ide_context_bar.vue'; - import repoTabs from './repo_tabs.vue'; - import repoFileButtons from './repo_file_buttons.vue'; - import ideStatusBar from './ide_status_bar.vue'; - import repoEditor from './repo_editor.vue'; +import { mapState, mapGetters } from 'vuex'; +import ideSidebar from './ide_side_bar.vue'; +import ideContextbar from './ide_context_bar.vue'; +import repoTabs from './repo_tabs.vue'; +import repoFileButtons from './repo_file_buttons.vue'; +import ideStatusBar from './ide_status_bar.vue'; +import repoEditor from './repo_editor.vue'; - export default { - components: { - ideSidebar, - ideContextbar, - repoTabs, - repoFileButtons, - ideStatusBar, - repoEditor, +export default { + components: { + ideSidebar, + ideContextbar, + repoTabs, + repoFileButtons, + ideStatusBar, + repoEditor, + }, + props: { + emptyStateSvgPath: { + type: String, + required: true, }, - props: { - emptyStateSvgPath: { - type: String, - required: true, - }, - noChangesStateSvgPath: { - type: String, - required: true, - }, - committedStateSvgPath: { - type: String, - required: true, - }, + noChangesStateSvgPath: { + type: String, + required: true, }, - computed: { - ...mapState(['changedFiles', 'openFiles', 'viewer']), - ...mapGetters(['activeFile', 'hasChanges']), + committedStateSvgPath: { + type: String, + required: true, }, - mounted() { - const returnValue = 'Are you sure you want to lose unsaved changes?'; - window.onbeforeunload = e => { - if (!this.changedFiles.length) return undefined; + }, + computed: { + ...mapState(['changedFiles', 'openFiles', 'viewer', 'currentMergeRequestId']), + ...mapGetters(['activeFile', 'hasChanges']), + }, + mounted() { + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = e => { + if (!this.changedFiles.length) return undefined; - Object.assign(e, { - returnValue, - }); - return returnValue; - }; - }, - }; + Object.assign(e, { + returnValue, + }); + return returnValue; + }; + }, +}; </script> <template> @@ -60,9 +60,11 @@ v-if="activeFile" > <repo-tabs + :active-file="activeFile" :files="openFiles" :viewer="viewer" :has-changes="hasChanges" + :merge-request-id="currentMergeRequestId" /> <repo-editor class="multi-file-edit-pane-content" diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue new file mode 100644 index 00000000000..8a440902dfc --- /dev/null +++ b/app/assets/javascripts/ide/components/mr_file_icon.vue @@ -0,0 +1,23 @@ +<script> +import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + components: { + icon, + }, + directives: { + tooltip, + }, +}; +</script> + +<template> + <icon + name="git-merge" + v-tooltip + title="__('Part of merge request changes')" + css-classes="ide-file-changed-icon" + :size="12" + /> +</template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index e73d1ce839f..b1a16350c19 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,6 +1,6 @@ <script> /* global monaco */ -import { mapState, mapActions } from 'vuex'; +import { mapState, mapGetters, mapActions } from 'vuex'; import flash from '~/flash'; import monacoLoader from '../monaco_loader'; import Editor from '../lib/editor'; @@ -13,19 +13,16 @@ export default { }, }, computed: { - ...mapState([ - 'leftPanelCollapsed', - 'rightPanelCollapsed', - 'viewer', - 'delayViewerUpdated', - ]), + ...mapState(['leftPanelCollapsed', 'rightPanelCollapsed', 'viewer', 'delayViewerUpdated']), + ...mapGetters(['currentMergeRequest']), shouldHideEditor() { return this.file && this.file.binary && !this.file.raw; }, }, watch: { file(oldVal, newVal) { - if (newVal.path !== this.file.path) { + // Compare key to allow for files opened in review mode to be cached differently + if (newVal.key !== this.file.key) { this.initMonaco(); } }, @@ -68,9 +65,14 @@ export default { this.editor.clearEditor(); - this.getRawFileData(this.file) + this.getRawFileData({ + path: this.file.path, + baseSha: this.currentMergeRequest ? this.currentMergeRequest.baseCommitSha : '', + }) .then(() => { - const viewerPromise = this.delayViewerUpdated ? this.updateViewer('editor') : Promise.resolve(); + const viewerPromise = this.delayViewerUpdated + ? this.updateViewer(this.file.pending ? 'diff' : 'editor') + : Promise.resolve(); return viewerPromise; }) @@ -78,7 +80,7 @@ export default { this.updateDelayViewerUpdated(false); this.createEditorInstance(); }) - .catch((err) => { + .catch(err => { flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true); throw err; }); @@ -101,9 +103,13 @@ export default { this.model = this.editor.createModel(this.file); - this.editor.attachModel(this.model); + if (this.viewer === 'mrdiff') { + this.editor.attachMergeRequestModel(this.model); + } else { + this.editor.attachModel(this.model); + } - this.model.onChange((model) => { + this.model.onChange(model => { const { file } = model; if (file.active) { diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index 297b9c2628f..3b5068d4910 100644 --- a/app/assets/javascripts/ide/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -6,6 +6,7 @@ import router from '../ide_router'; import newDropdown from './new_dropdown/index.vue'; import fileStatusIcon from './repo_file_status_icon.vue'; import changedFileIcon from './changed_file_icon.vue'; +import mrFileIcon from './mr_file_icon.vue'; export default { name: 'RepoFile', @@ -15,6 +16,7 @@ export default { fileStatusIcon, fileIcon, changedFileIcon, + mrFileIcon, }, props: { file: { @@ -56,18 +58,11 @@ export default { ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']), clickFile() { // Manual Action if a tree is selected/opened - if ( - this.isTree && - this.$router.currentRoute.path === `/project${this.file.url}` - ) { + if (this.isTree && this.$router.currentRoute.path === `/project${this.file.url}`) { this.toggleTreeOpen(this.file.path); } - const delayPromise = this.file.changed - ? Promise.resolve() - : this.updateDelayViewerUpdated(true); - - return delayPromise.then(() => { + return this.updateDelayViewerUpdated(true).then(() => { router.push(`/project${this.file.url}`); }); }, @@ -102,11 +97,15 @@ export default { :file="file" /> </span> - <changed-file-icon - :file="file" - v-if="file.changed || file.tempFile" - class="prepend-top-5 pull-right" - /> + <span class="pull-right"> + <mr-file-icon + v-if="file.mrChange" + /> + <changed-file-icon + :file="file" + v-if="file.changed || file.tempFile" + /> + </span> <new-dropdown v-if="isTree" :project-id="file.projectId" diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue index 25d311142d5..97589e116c5 100644 --- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -1,27 +1,27 @@ <script> - import icon from '~/vue_shared/components/icon.vue'; - import tooltip from '~/vue_shared/directives/tooltip'; - import '~/lib/utils/datetime_utility'; +import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import '~/lib/utils/datetime_utility'; - export default { - components: { - icon, +export default { + components: { + icon, + }, + directives: { + tooltip, + }, + props: { + file: { + type: Object, + required: true, }, - directives: { - tooltip, + }, + computed: { + lockTooltip() { + return `Locked by ${this.file.file_lock.user.name}`; }, - props: { - file: { - type: Object, - required: true, - }, - }, - computed: { - lockTooltip() { - return `Locked by ${this.file.file_lock.user.name}`; - }, - }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index c337bc813e6..304a73ed1ad 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -1,60 +1,64 @@ <script> - import { mapActions } from 'vuex'; +import { mapActions } from 'vuex'; - import fileIcon from '~/vue_shared/components/file_icon.vue'; - import icon from '~/vue_shared/components/icon.vue'; - import fileStatusIcon from './repo_file_status_icon.vue'; - import changedFileIcon from './changed_file_icon.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import FileStatusIcon from './repo_file_status_icon.vue'; +import ChangedFileIcon from './changed_file_icon.vue'; - export default { - components: { - fileStatusIcon, - fileIcon, - icon, - changedFileIcon, +export default { + components: { + FileStatusIcon, + FileIcon, + Icon, + ChangedFileIcon, + }, + props: { + tab: { + type: Object, + required: true, }, - props: { - tab: { - type: Object, - required: true, - }, + }, + data() { + return { + tabMouseOver: false, + }; + }, + computed: { + closeLabel() { + if (this.tab.changed || this.tab.tempFile) { + return `${this.tab.name} changed`; + } + return `Close ${this.tab.name}`; }, - data() { - return { - tabMouseOver: false, - }; - }, - computed: { - closeLabel() { - if (this.tab.changed || this.tab.tempFile) { - return `${this.tab.name} changed`; - } - return `Close ${this.tab.name}`; - }, - showChangedIcon() { - return this.tab.changed ? !this.tabMouseOver : false; - }, + showChangedIcon() { + return this.tab.changed ? !this.tabMouseOver : false; }, + }, + + methods: { + ...mapActions(['closeFile', 'updateDelayViewerUpdated', 'openPendingTab']), + clickFile(tab) { + this.updateDelayViewerUpdated(true); - methods: { - ...mapActions([ - 'closeFile', - ]), - clickFile(tab) { + if (tab.pending) { + this.openPendingTab(tab); + } else { this.$router.push(`/project${tab.url}`); - }, - mouseOverTab() { - if (this.tab.changed) { - this.tabMouseOver = true; - } - }, - mouseOutTab() { - if (this.tab.changed) { - this.tabMouseOver = false; - } - }, + } + }, + mouseOverTab() { + if (this.tab.changed) { + this.tabMouseOver = true; + } + }, + mouseOutTab() { + if (this.tab.changed) { + this.tabMouseOver = false; + } }, - }; + }, +}; </script> <template> @@ -66,7 +70,7 @@ <button type="button" class="multi-file-tab-close" - @click.stop.prevent="closeFile(tab.path)" + @click.stop.prevent="closeFile(tab)" :aria-label="closeLabel" > <icon @@ -82,7 +86,9 @@ <div class="multi-file-tab" - :class="{active : tab.active }" + :class="{ + active: tab.active + }" :title="tab.url" > <file-icon diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 8ea64ddf84a..7bd646ba9b0 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,42 +1,62 @@ <script> - import { mapActions } from 'vuex'; - import RepoTab from './repo_tab.vue'; - import EditorMode from './editor_mode_dropdown.vue'; +import { mapActions } from 'vuex'; +import RepoTab from './repo_tab.vue'; +import EditorMode from './editor_mode_dropdown.vue'; +import router from '../ide_router'; - export default { - components: { - RepoTab, - EditorMode, +export default { + components: { + RepoTab, + EditorMode, + }, + props: { + activeFile: { + type: Object, + required: true, }, - props: { - files: { - type: Array, - required: true, - }, - viewer: { - type: String, - required: true, - }, - hasChanges: { - type: Boolean, - required: true, - }, + files: { + type: Array, + required: true, }, - data() { - return { - showShadow: false, - }; + viewer: { + type: String, + required: true, }, - updated() { - if (!this.$refs.tabsScroller) return; - - this.showShadow = - this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; + hasChanges: { + type: Boolean, + required: true, + }, + mergeRequestId: { + type: String, + required: false, + default: '', }, - methods: { - ...mapActions(['updateViewer']), + }, + data() { + return { + showShadow: false, + }; + }, + updated() { + if (!this.$refs.tabsScroller) return; + + this.showShadow = this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; + }, + methods: { + ...mapActions(['updateViewer', 'removePendingTab']), + openFileViewer(viewer) { + this.updateViewer(viewer); + + if (this.activeFile.pending) { + return this.removePendingTab(this.activeFile).then(() => { + router.push(`/project${this.activeFile.url}`); + }); + } + + return null; }, - }; + }, +}; </script> <template> @@ -55,7 +75,8 @@ :viewer="viewer" :show-shadow="showShadow" :has-changes="hasChanges" - @click="updateViewer" + :merge-request-id="mergeRequestId" + @click="openFileViewer" /> </div> </template> diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index db89c1d44db..20983666b4a 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -44,7 +44,7 @@ const router = new VueRouter({ component: EmptyRouterComponent, }, { - path: 'mr/:mrid', + path: 'merge_requests/:mrid', component: EmptyRouterComponent, }, ], @@ -76,10 +76,12 @@ router.beforeEach((to, from, next) => { .then(() => { if (to.params[0]) { const path = - to.params[0].slice(-1) === '/' - ? to.params[0].slice(0, -1) - : to.params[0]; - const treeEntry = store.state.entries[path]; + to.params[0].slice(-1) === '/' ? to.params[0].slice(0, -1) : to.params[0]; + const treeEntryKey = Object.keys(store.state.entries).find( + key => key === path && !store.state.entries[key].pending, + ); + const treeEntry = store.state.entries[treeEntryKey]; + if (treeEntry) { store.dispatch('handleTreeEntryAction', treeEntry); } @@ -96,6 +98,60 @@ router.beforeEach((to, from, next) => { ); throw e; }); + } else if (to.params.mrid) { + store.dispatch('updateViewer', 'mrdiff'); + + store + .dispatch('getMergeRequestData', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }) + .then(mr => { + store.dispatch('getBranchData', { + projectId: fullProjectId, + branchId: mr.source_branch, + }); + + return store.dispatch('getFiles', { + projectId: fullProjectId, + branchId: mr.source_branch, + }); + }) + .then(() => + store.dispatch('getMergeRequestVersions', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }), + ) + .then(() => + store.dispatch('getMergeRequestChanges', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }), + ) + .then(mrChanges => { + mrChanges.changes.forEach((change, ind) => { + const changeTreeEntry = store.state.entries[change.new_path]; + + if (changeTreeEntry) { + store.dispatch('setFileMrChange', { + file: changeTreeEntry, + mrChange: change, + }); + + if (ind < 10) { + store.dispatch('getFileData', { + path: change.new_path, + makeFileActive: ind === 0, + }); + } + } + }); + }) + .catch(e => { + flash('Error while loading the merge request. Please try again.'); + throw e; + }); } }) .catch(e => { diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 73cd684351c..e47adae99ed 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -13,25 +13,31 @@ export default class Model { (this.originalModel = this.monaco.editor.createModel( this.file.raw, undefined, - new this.monaco.Uri(null, null, `original/${this.file.path}`), + new this.monaco.Uri(null, null, `original/${this.file.key}`), )), (this.model = this.monaco.editor.createModel( this.content, undefined, - new this.monaco.Uri(null, null, this.file.path), + new this.monaco.Uri(null, null, this.file.key), )), ); + if (this.file.mrChange) { + this.disposable.add( + (this.baseModel = this.monaco.editor.createModel( + this.file.baseRaw, + undefined, + new this.monaco.Uri(null, null, `target/${this.file.path}`), + )), + ); + } this.events = new Map(); this.updateContent = this.updateContent.bind(this); this.dispose = this.dispose.bind(this); - eventHub.$on(`editor.update.model.dispose.${this.file.path}`, this.dispose); - eventHub.$on( - `editor.update.model.content.${this.file.path}`, - this.updateContent, - ); + eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose); + eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent); } get url() { @@ -47,7 +53,7 @@ export default class Model { } get path() { - return this.file.path; + return this.file.key; } getModel() { @@ -58,6 +64,10 @@ export default class Model { return this.originalModel; } + getBaseModel() { + return this.baseModel; + } + setValue(value) { this.getModel().setValue(value); } @@ -78,13 +88,7 @@ export default class Model { this.disposable.dispose(); this.events.clear(); - eventHub.$off( - `editor.update.model.dispose.${this.file.path}`, - this.dispose, - ); - eventHub.$off( - `editor.update.model.content.${this.file.path}`, - this.updateContent, - ); + eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); + eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent); } } diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js index 57d5e59a88b..0e7b563b5d6 100644 --- a/app/assets/javascripts/ide/lib/common/model_manager.js +++ b/app/assets/javascripts/ide/lib/common/model_manager.js @@ -9,17 +9,17 @@ export default class ModelManager { this.models = new Map(); } - hasCachedModel(path) { - return this.models.has(path); + hasCachedModel(key) { + return this.models.has(key); } - getModel(path) { - return this.models.get(path); + getModel(key) { + return this.models.get(key); } addModel(file) { - if (this.hasCachedModel(file.path)) { - return this.getModel(file.path); + if (this.hasCachedModel(file.key)) { + return this.getModel(file.key); } const model = new Model(this.monaco, file); @@ -27,7 +27,7 @@ export default class ModelManager { this.disposable.add(model); eventHub.$on( - `editor.update.model.dispose.${file.path}`, + `editor.update.model.dispose.${file.key}`, this.removeCachedModel.bind(this, file), ); @@ -35,12 +35,9 @@ export default class ModelManager { } removeCachedModel(file) { - this.models.delete(file.path); + this.models.delete(file.key); - eventHub.$off( - `editor.update.model.dispose.${file.path}`, - this.removeCachedModel, - ); + eventHub.$off(`editor.update.model.dispose.${file.key}`, this.removeCachedModel); } dispose() { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 887dd7e39b1..6b4ba30e086 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -109,11 +109,19 @@ export default class Editor { if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model); } + attachMergeRequestModel(model) { + this.instance.setModel({ + original: model.getBaseModel(), + modified: model.getModel(), + }); + + this.monaco.editor.createDiffNavigator(this.instance, { + alwaysRevealFirst: true, + }); + } + setupMonacoTheme() { - this.monaco.editor.defineTheme( - gitlabTheme.themeName, - gitlabTheme.monacoTheme, - ); + this.monaco.editor.defineTheme(gitlabTheme.themeName, gitlabTheme.monacoTheme); this.monaco.editor.setTheme('gitlab'); } @@ -161,8 +169,6 @@ export default class Editor { onPositionChange(cb) { if (!this.instance.onDidChangeCursorPosition) return; - this.disposable.add( - this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)), - ); + this.disposable.add(this.instance.onDidChangeCursorPosition(e => cb(this.instance, e))); } } diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 5f1fb6cf843..a12e637616a 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -20,12 +20,35 @@ export default { return Promise.resolve(file.raw); } - return Vue.http.get(file.rawPath, { params: { format: 'json' } }) + return Vue.http.get(file.rawPath, { params: { format: 'json' } }).then(res => res.text()); + }, + getBaseRawFileData(file, sha) { + if (file.tempFile) { + return Promise.resolve(file.baseRaw); + } + + if (file.baseRaw) { + return Promise.resolve(file.baseRaw); + } + + return Vue.http + .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), { + params: { format: 'json' }, + }) .then(res => res.text()); }, getProjectData(namespace, project) { return Api.project(`${namespace}/${project}`); }, + getProjectMergeRequestData(projectId, mergeRequestId) { + return Api.mergeRequest(projectId, mergeRequestId); + }, + getProjectMergeRequestChanges(projectId, mergeRequestId) { + return Api.mergeRequestChanges(projectId, mergeRequestId); + }, + getProjectMergeRequestVersions(projectId, mergeRequestId) { + return Api.mergeRequestVersions(projectId, mergeRequestId); + }, getBranchData(projectId, currentBranchId) { return Api.branchSingle(projectId, currentBranchId); }, diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 7e920aa9f30..c6ba679d99c 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -6,8 +6,7 @@ import FilesDecoratorWorker from './workers/files_decorator_worker'; export const redirectToUrl = (_, url) => visitUrl(url); -export const setInitialData = ({ commit }, data) => - commit(types.SET_INITIAL_DATA, data); +export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); export const discardAllChanges = ({ state, commit, dispatch }) => { state.changedFiles.forEach(file => { @@ -22,7 +21,7 @@ export const discardAllChanges = ({ state, commit, dispatch }) => { }; export const closeAllFiles = ({ state, dispatch }) => { - state.openFiles.forEach(file => dispatch('closeFile', file.path)); + state.openFiles.forEach(file => dispatch('closeFile', file)); }; export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { @@ -43,14 +42,11 @@ export const createTempEntry = ( ) => new Promise(resolve => { const worker = new FilesDecoratorWorker(); - const fullName = - name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; + const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; if (state.entries[name]) { flash( - `The name "${name - .split('/') - .pop()}" is already taken in this directory.`, + `The name "${name.split('/').pop()}" is already taken in this directory.`, 'alert', document, null, @@ -119,3 +115,4 @@ export const updateDelayViewerUpdated = ({ commit }, delay) => { export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; +export * from './actions/merge_request'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index ddc4b757bf9..6b034ea1e82 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -6,24 +6,34 @@ import * as types from '../mutation_types'; import router from '../../ide_router'; import { setPageTitle } from '../utils'; -export const closeFile = ({ commit, state, getters, dispatch }, path) => { - const indexOfClosedFile = state.openFiles.findIndex(f => f.path === path); - const file = state.entries[path]; +export const closeFile = ({ commit, state, dispatch }, file) => { + const path = file.path; + const indexOfClosedFile = state.openFiles.findIndex(f => f.key === file.key); const fileWasActive = file.active; - commit(types.TOGGLE_FILE_OPEN, path); - commit(types.SET_FILE_ACTIVE, { path, active: false }); + if (file.pending) { + commit(types.REMOVE_PENDING_TAB, file); + } else { + commit(types.TOGGLE_FILE_OPEN, path); + commit(types.SET_FILE_ACTIVE, { path, active: false }); + } if (state.openFiles.length > 0 && fileWasActive) { const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; - const nextFileToOpen = state.entries[state.openFiles[nextIndexToOpen].path]; - - router.push(`/project${nextFileToOpen.url}`); + const nextFileToOpen = state.openFiles[nextIndexToOpen]; + + if (nextFileToOpen.pending) { + dispatch('updateViewer', 'diff'); + dispatch('openPendingTab', nextFileToOpen); + } else { + dispatch('updateDelayViewerUpdated', true); + router.push(`/project${nextFileToOpen.url}`); + } } else if (!state.openFiles.length) { router.push(`/project/${file.projectId}/tree/${file.branchId}/`); } - eventHub.$emit(`editor.update.model.dispose.${file.path}`); + eventHub.$emit(`editor.update.model.dispose.${file.key}`); }; export const setFileActive = ({ commit, state, getters, dispatch }, path) => { @@ -46,53 +56,63 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { commit(types.SET_CURRENT_BRANCH, file.branchId); }; -export const getFileData = ({ state, commit, dispatch }, file) => { +export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive = true }) => { + const file = state.entries[path]; commit(types.TOGGLE_LOADING, { entry: file }); - return service .getFileData(file.url) .then(res => { const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); - setPageTitle(pageTitle); return res.json(); }) .then(data => { commit(types.SET_FILE_DATA, { data, file }); - commit(types.TOGGLE_FILE_OPEN, file.path); - dispatch('setFileActive', file.path); + commit(types.TOGGLE_FILE_OPEN, path); + if (makeFileActive) dispatch('setFileActive', path); commit(types.TOGGLE_LOADING, { entry: file }); }) .catch(() => { commit(types.TOGGLE_LOADING, { entry: file }); - flash( - 'Error loading file data. Please try again.', - 'alert', - document, - null, - false, - true, - ); + flash('Error loading file data. Please try again.', 'alert', document, null, false, true); }); }; -export const getRawFileData = ({ commit, dispatch }, file) => - service - .getRawFileData(file) - .then(raw => { - commit(types.SET_FILE_RAW_DATA, { file, raw }); - }) - .catch(() => - flash( - 'Error loading file content. Please try again.', - 'alert', - document, - null, - false, - true, - ), - ); +export const setFileMrChange = ({ state, commit }, { file, mrChange }) => { + commit(types.SET_FILE_MERGE_REQUEST_CHANGE, { file, mrChange }); +}; + +export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) => { + const file = state.entries[path]; + return new Promise((resolve, reject) => { + service + .getRawFileData(file) + .then(raw => { + commit(types.SET_FILE_RAW_DATA, { file, raw }); + if (file.mrChange && file.mrChange.new_file === false) { + service + .getBaseRawFileData(file, baseSha) + .then(baseRaw => { + commit(types.SET_FILE_BASE_RAW_DATA, { + file, + baseRaw, + }); + resolve(raw); + }) + .catch(e => { + reject(e); + }); + } else { + resolve(raw); + } + }) + .catch(() => { + flash('Error loading file content. Please try again.'); + reject(); + }); + }); +}; export const changeFileContent = ({ state, commit }, { path, content }) => { const file = state.entries[path]; @@ -119,10 +139,7 @@ export const setFileEOL = ({ getters, commit }, { eol }) => { } }; -export const setEditorPosition = ( - { getters, commit }, - { editorRow, editorColumn }, -) => { +export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => { if (getters.activeFile) { commit(types.SET_FILE_POSITION, { file: getters.activeFile, @@ -144,3 +161,23 @@ export const discardFileChanges = ({ state, commit }, path) => { eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw); }; + +export const openPendingTab = ({ commit, getters, dispatch, state }, file) => { + if (getters.activeFile && getters.activeFile.path === file.path && state.viewer === 'diff') { + return false; + } + + commit(types.ADD_PENDING_TAB, { file }); + + dispatch('scrollToTab'); + + router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`); + + return true; +}; + +export const removePendingTab = ({ commit }, file) => { + commit(types.REMOVE_PENDING_TAB, file); + + eventHub.$emit(`editor.update.model.dispose.${file.key}`); +}; diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js new file mode 100644 index 00000000000..da73034fd7d --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -0,0 +1,84 @@ +import flash from '~/flash'; +import service from '../../services'; +import * as types from '../mutation_types'; + +export const getMergeRequestData = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) { + service + .getProjectMergeRequestData(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST, { + projectPath: projectId, + mergeRequestId, + mergeRequest: data, + }); + if (!state.currentMergeRequestId) { + commit(types.SET_CURRENT_MERGE_REQUEST, mergeRequestId); + } + resolve(data); + }) + .catch(() => { + flash('Error loading merge request data. Please try again.'); + reject(new Error(`Merge Request not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId]); + } + }); + +export const getMergeRequestChanges = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if (!state.projects[projectId].mergeRequests[mergeRequestId].changes.length || force) { + service + .getProjectMergeRequestChanges(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST_CHANGES, { + projectPath: projectId, + mergeRequestId, + changes: data, + }); + resolve(data); + }) + .catch(() => { + flash('Error loading merge request changes. Please try again.'); + reject(new Error(`Merge Request Changes not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId].changes); + } + }); + +export const getMergeRequestVersions = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if (!state.projects[projectId].mergeRequests[mergeRequestId].versions.length || force) { + service + .getProjectMergeRequestVersions(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST_VERSIONS, { + projectPath: projectId, + mergeRequestId, + versions: data, + }); + resolve(data); + }) + .catch(() => { + flash('Error loading merge request versions. Please try again.'); + reject(new Error(`Merge Request Versions not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId].versions); + } + }); diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 70a969a0325..6536be04f0a 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -2,9 +2,7 @@ import { normalizeHeaders } from '~/lib/utils/common_utils'; import flash from '~/flash'; import service from '../../services'; import * as types from '../mutation_types'; -import { - findEntry, -} from '../utils'; +import { findEntry } from '../utils'; import FilesDecoratorWorker from '../workers/files_decorator_worker'; export const toggleTreeOpen = ({ commit, dispatch }, path) => { @@ -21,23 +19,24 @@ export const handleTreeEntryAction = ({ commit, dispatch }, row) => { dispatch('setFileActive', row.path); } else { - dispatch('getFileData', row); + dispatch('getFileData', { path: row.path }); } }; export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; - service.getTreeLastCommit(tree.lastCommitPath) - .then((res) => { + service + .getTreeLastCommit(tree.lastCommitPath) + .then(res => { const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); return res.json(); }) - .then((data) => { - data.forEach((lastCommit) => { + .then(data => { + data.forEach(lastCommit => { const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); if (entry) { @@ -50,44 +49,47 @@ export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = s .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true)); }; -export const getFiles = ( - { state, commit, dispatch }, - { projectId, branchId } = {}, -) => new Promise((resolve, reject) => { - if (!state.trees[`${projectId}/${branchId}`]) { - const selectedProject = state.projects[projectId]; - commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); - - service - .getFiles(selectedProject.web_url, branchId) - .then(res => res.json()) - .then((data) => { - const worker = new FilesDecoratorWorker(); - worker.addEventListener('message', (e) => { - const { entries, treeList } = e.data; - const selectedTree = state.trees[`${projectId}/${branchId}`]; - - commit(types.SET_ENTRIES, entries); - commit(types.SET_DIRECTORY_DATA, { treePath: `${projectId}/${branchId}`, data: treeList }); - commit(types.TOGGLE_LOADING, { entry: selectedTree, forceValue: false }); - - worker.terminate(); - - resolve(); - }); - - worker.postMessage({ - data, - projectId, - branchId, +export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) => + new Promise((resolve, reject) => { + if (!state.trees[`${projectId}/${branchId}`]) { + const selectedProject = state.projects[projectId]; + commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); + + service + .getFiles(selectedProject.web_url, branchId) + .then(res => res.json()) + .then(data => { + const worker = new FilesDecoratorWorker(); + worker.addEventListener('message', e => { + const { entries, treeList } = e.data; + const selectedTree = state.trees[`${projectId}/${branchId}`]; + + commit(types.SET_ENTRIES, entries); + commit(types.SET_DIRECTORY_DATA, { + treePath: `${projectId}/${branchId}`, + data: treeList, + }); + commit(types.TOGGLE_LOADING, { + entry: selectedTree, + forceValue: false, + }); + + worker.terminate(); + + resolve(); + }); + + worker.postMessage({ + data, + projectId, + branchId, + }); + }) + .catch(e => { + flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); + reject(e); }); - }) - .catch((e) => { - flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); - reject(e); - }); - } else { - resolve(); - } -}); - + } else { + resolve(); + } + }); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index eba325a31df..a77cdbc13c8 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,10 +1,8 @@ -export const activeFile = state => - state.openFiles.find(file => file.active) || null; +export const activeFile = state => state.openFiles.find(file => file.active) || null; export const addedFiles = state => state.changedFiles.filter(f => f.tempFile); -export const modifiedFiles = state => - state.changedFiles.filter(f => !f.tempFile); +export const modifiedFiles = state => state.changedFiles.filter(f => !f.tempFile); export const projectsWithTrees = state => Object.keys(state.projects).map(projectId => { @@ -23,8 +21,17 @@ export const projectsWithTrees = state => }; }); +export const currentMergeRequest = state => { + if (state.projects[state.currentProjectId]) { + return state.projects[state.currentProjectId].mergeRequests[state.currentMergeRequestId]; + } + return null; +}; + // eslint-disable-next-line no-confusing-arrow export const currentIcon = state => state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; export const hasChanges = state => !!state.changedFiles.length; + +export const hasMergeRequest = state => !!state.currentMergeRequestId; diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index e28f190897c..ee759bff516 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -11,6 +11,12 @@ export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; +// Merge Request Mutation Types +export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST'; +export const SET_CURRENT_MERGE_REQUEST = 'SET_CURRENT_MERGE_REQUEST'; +export const SET_MERGE_REQUEST_CHANGES = 'SET_MERGE_REQUEST_CHANGES'; +export const SET_MERGE_REQUEST_VERSIONS = 'SET_MERGE_REQUEST_VERSIONS'; + // Branch Mutation Types export const SET_BRANCH = 'SET_BRANCH'; export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; @@ -28,6 +34,7 @@ export const SET_FILE_DATA = 'SET_FILE_DATA'; export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN'; export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; +export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA'; export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; export const SET_FILE_POSITION = 'SET_FILE_POSITION'; @@ -39,5 +46,9 @@ export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED'; export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; export const SET_ENTRIES = 'SET_ENTRIES'; export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY'; +export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE'; export const UPDATE_VIEWER = 'UPDATE_VIEWER'; export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; + +export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; +export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index da41fc9285c..5e5eb831662 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import projectMutations from './mutations/project'; +import mergeRequestMutation from './mutations/merge_request'; import fileMutations from './mutations/file'; import treeMutations from './mutations/tree'; import branchMutations from './mutations/branch'; @@ -11,10 +12,7 @@ export default { [types.TOGGLE_LOADING](state, { entry, forceValue = undefined }) { if (entry.path) { Object.assign(state.entries[entry.path], { - loading: - forceValue !== undefined - ? forceValue - : !state.entries[entry.path].loading, + loading: forceValue !== undefined ? forceValue : !state.entries[entry.path].loading, }); } else { Object.assign(entry, { @@ -83,9 +81,7 @@ export default { if (!foundEntry) { Object.assign(state.trees[`${projectId}/${branchId}`], { - tree: state.trees[`${projectId}/${branchId}`].tree.concat( - data.treeList, - ), + tree: state.trees[`${projectId}/${branchId}`].tree.concat(data.treeList), }); } }, @@ -100,6 +96,7 @@ export default { }); }, ...projectMutations, + ...mergeRequestMutation, ...fileMutations, ...treeMutations, ...branchMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 2500f13db7c..926b6f66d78 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -5,6 +5,14 @@ export default { Object.assign(state.entries[path], { active, }); + + if (active && !state.entries[path].pending) { + Object.assign(state, { + openFiles: state.openFiles.map(f => + Object.assign(f, { active: f.pending ? false : f.active }), + ), + }); + } }, [types.TOGGLE_FILE_OPEN](state, path) { Object.assign(state.entries[path], { @@ -12,10 +20,14 @@ export default { }); if (state.entries[path].opened) { - state.openFiles.push(state.entries[path]); + Object.assign(state, { + openFiles: state.openFiles.filter(f => f.path !== path).concat(state.entries[path]), + }); } else { + const file = state.entries[path]; + Object.assign(state, { - openFiles: state.openFiles.filter(f => f.path !== path), + openFiles: state.openFiles.filter(f => f.key !== file.key), }); } }, @@ -28,6 +40,8 @@ export default { rawPath: data.raw_path, binary: data.binary, renderError: data.render_error, + raw: null, + baseRaw: null, }); }, [types.SET_FILE_RAW_DATA](state, { file, raw }) { @@ -35,6 +49,11 @@ export default { raw, }); }, + [types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) { + Object.assign(state.entries[file.path], { + baseRaw, + }); + }, [types.UPDATE_FILE_CONTENT](state, { path, content }) { const changed = content !== state.entries[path].raw; @@ -59,6 +78,11 @@ export default { editorColumn, }); }, + [types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) { + Object.assign(state.entries[file.path], { + mrChange, + }); + }, [types.DISCARD_FILE_CHANGES](state, path) { Object.assign(state.entries[path], { content: state.entries[path].raw, @@ -80,4 +104,37 @@ export default { changed, }); }, + [types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) { + const pendingTab = state.openFiles.find(f => f.path === file.path && f.pending); + let openFiles = state.openFiles.map(f => + Object.assign(f, { active: f.path === file.path, opened: false }), + ); + + if (!pendingTab) { + const openFile = openFiles.find(f => f.path === file.path); + + openFiles = openFiles.concat(openFile ? null : file).reduce((acc, f) => { + if (!f) return acc; + + if (f.path === file.path) { + return acc.concat({ + ...f, + active: true, + pending: true, + opened: true, + key: `${keyPrefix}-${f.key}`, + }); + } + + return acc.concat(f); + }, []); + } + + Object.assign(state, { openFiles }); + }, + [types.REMOVE_PENDING_TAB](state, file) { + Object.assign(state, { + openFiles: state.openFiles.filter(f => f.key !== file.key), + }); + }, }; diff --git a/app/assets/javascripts/ide/stores/mutations/merge_request.js b/app/assets/javascripts/ide/stores/mutations/merge_request.js new file mode 100644 index 00000000000..334819fe702 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/merge_request.js @@ -0,0 +1,33 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_MERGE_REQUEST](state, currentMergeRequestId) { + Object.assign(state, { + currentMergeRequestId, + }); + }, + [types.SET_MERGE_REQUEST](state, { projectPath, mergeRequestId, mergeRequest }) { + Object.assign(state.projects[projectPath], { + mergeRequests: { + [mergeRequestId]: { + ...mergeRequest, + active: true, + changes: [], + versions: [], + baseCommitSha: null, + }, + }, + }); + }, + [types.SET_MERGE_REQUEST_CHANGES](state, { projectPath, mergeRequestId, changes }) { + Object.assign(state.projects[projectPath].mergeRequests[mergeRequestId], { + changes, + }); + }, + [types.SET_MERGE_REQUEST_VERSIONS](state, { projectPath, mergeRequestId, versions }) { + Object.assign(state.projects[projectPath].mergeRequests[mergeRequestId], { + versions, + baseCommitSha: versions.length ? versions[0].base_commit_sha : null, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js index 2816562a919..284b39a2c72 100644 --- a/app/assets/javascripts/ide/stores/mutations/project.js +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -11,6 +11,7 @@ export default { Object.assign(project, { tree: [], branches: {}, + mergeRequests: {}, active: true, }); diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 6110f54951c..e5cc8814000 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -1,6 +1,7 @@ export default () => ({ currentProjectId: '', currentBranchId: '', + currentMergeRequestId: '', changedFiles: [], endpoints: {}, lastCommitMsg: '', diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 487ea1ead8e..63e4de3b17d 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,5 +1,7 @@ export const dataStructure = () => ({ id: '', + // Key will contain a mixture of ID and path + // it can also contain a prefix `pending-` for files opened in review mode key: '', type: '', projectId: '', @@ -38,7 +40,7 @@ export const dataStructure = () => ({ eol: '', }); -export const decorateData = (entity) => { +export const decorateData = entity => { const { id, projectId, @@ -57,7 +59,6 @@ export const decorateData = (entity) => { base64 = false, file_lock, - } = entity; return { @@ -80,17 +81,15 @@ export const decorateData = (entity) => { base64, file_lock, - }; }; -export const findEntry = (tree, type, name, prop = 'name') => tree.find( - f => f.type === type && f[prop] === name, -); +export const findEntry = (tree, type, name, prop = 'name') => + tree.find(f => f.type === type && f[prop] === name); export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); -export const setPageTitle = (title) => { +export const setPageTitle = title => { document.title = title; }; @@ -120,6 +119,11 @@ const sortTreesByTypeAndName = (a, b) => { return 0; }; -export const sortTree = sortedTree => sortedTree.map(entity => Object.assign(entity, { - tree: entity.tree.length ? sortTree(entity.tree) : [], -})).sort(sortTreesByTypeAndName); +export const sortTree = sortedTree => + sortedTree + .map(entity => + Object.assign(entity, { + tree: entity.tree.length ? sortTree(entity.tree) : [], + }), + ) + .sort(sortTreesByTypeAndName); diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue index a6819aaeb12..dfe87d89a39 100644 --- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue +++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue @@ -11,11 +11,19 @@ type: String, required: true, }, + helpUrl: { + type: String, + required: false, + default: '', + }, }, computed: { hasTitle() { return this.title.length > 0; }, + hasHelpURL() { + return this.helpUrl.length > 0; + }, }, }; </script> @@ -28,5 +36,21 @@ {{ title }}: </span> {{ value }} + + <span + v-if="hasHelpURL" + class="help-button pull-right" + > + <a + :href="helpUrl" + target="_blank" + rel="noopener noreferrer nofollow" + > + <i + class="fa fa-question-circle" + aria-hidden="true" + ></i> + </a> + </span> </p> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index 56814a52525..172de6b3679 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -22,6 +22,11 @@ type: Boolean, required: true, }, + runnerHelpUrl: { + type: String, + required: false, + default: '', + }, }, computed: { shouldRenderContent() { @@ -39,6 +44,21 @@ runnerId() { return `#${this.job.runner.id}`; }, + hasTimeout() { + return this.job.metadata != null && this.job.metadata.timeout_human_readable !== ''; + }, + timeout() { + if (this.job.metadata == null) { + return ''; + } + + let t = this.job.metadata.timeout_human_readable; + if (this.job.metadata.timeout_source !== '') { + t += ` (from ${this.job.metadata.timeout_source})`; + } + + return t; + }, renderBlock() { return this.job.merge_request || this.job.duration || @@ -115,6 +135,13 @@ :value="queued" /> <detail-row + class="js-job-timeout" + v-if="hasTimeout" + title="Timeout" + :help-url="runnerHelpUrl" + :value="timeout" + /> + <detail-row class="js-job-runner" v-if="job.runner" title="Runner" diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js index 85a88ae409b..656676ead91 100644 --- a/app/assets/javascripts/jobs/job_details_bundle.js +++ b/app/assets/javascripts/jobs/job_details_bundle.js @@ -51,6 +51,7 @@ export default () => { props: { isLoading: this.mediator.state.isLoading, job: this.mediator.store.state.job, + runnerHelpUrl: dataset.runnerHelpUrl, }, }); }, diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 0830ebe9e4e..9ff2042475b 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -33,6 +33,7 @@ export const checkPageAndAction = (page, action) => { export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); +export const isInEpicPage = () => checkPageAndAction('epics', 'show'); export const isInNoteablePage = () => isInIssuePage() || isInMRPage(); export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions'); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index a266bb6771f..dd17544b656 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -51,7 +51,7 @@ export function removeParams(params) { const url = document.createElement('a'); url.href = window.location.href; - params.forEach((param) => { + params.forEach(param => { url.search = removeParamQueryString(url.search, param); }); @@ -83,3 +83,11 @@ export function refreshCurrentPage() { export function redirectTo(url) { return window.location.assign(url); } + +export function webIDEUrl(route = undefined) { + let returnUrl = `${gon.relative_url_root}/-/ide/`; + if (route) { + returnUrl += `project${route}`; + } + return returnUrl; +} diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 90dcafd75b7..648fa6ff804 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -99,6 +99,10 @@ export default { 'js-note-target-reopen': !this.isOpen, }; }, + supportQuickActions() { + // Disable quick actions support for Epics + return this.noteableType !== constants.EPIC_NOTEABLE_TYPE; + }, markdownDocsPath() { return this.getNotesData.markdownDocsPath; }, @@ -355,7 +359,7 @@ Please check your network connection and try again.`; name="note[note]" class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea" - data-supports-quick-actions="true" + :data-supports-quick-actions="supportQuickActions" aria-label="Description" v-model="note" ref="textarea" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index cf579c5d4dc..e0f883a8e08 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -292,10 +292,12 @@ Please check your network connection and try again.`; </button> </div> <div + v-if="note.resolvable" class="btn-group discussion-actions" - role="group"> + role="group" + > <div - v-if="note.resolvable && !discussionResolved" + v-if="!discussionResolved" class="btn-group" role="group"> <a diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index a90c6d6381d..5bd81c7cad6 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -50,7 +50,11 @@ export default { ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']), noteableType() { // FIXME -- @fatihacet Get this from JSON data. - const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; + const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE, EPIC_NOTEABLE_TYPE } = constants; + + if (this.noteableData.noteableType === EPIC_NOTEABLE_TYPE) { + return EPIC_NOTEABLE_TYPE; + } return this.noteableData.merge_params ? MERGE_REQUEST_NOTEABLE_TYPE diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index f4f407ffd8a..68f8cb1cf1e 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -10,6 +10,7 @@ export const CLOSED = 'closed'; export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; export const ISSUE_NOTEABLE_TYPE = 'issue'; +export const EPIC_NOTEABLE_TYPE = 'epic'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index f90775d0157..e4121f151db 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -12,8 +12,11 @@ document.addEventListener( data() { const notesDataset = document.getElementById('js-vue-notes').dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); + const noteableData = JSON.parse(notesDataset.noteableData); let currentUserData = {}; + noteableData.noteableType = notesDataset.noteableType; + if (parsedUserData) { currentUserData = { id: parsedUserData.id, @@ -25,7 +28,7 @@ document.addEventListener( } return { - noteableData: JSON.parse(notesDataset.noteableData), + noteableData, currentUserData, notesData: JSON.parse(notesDataset.notesData), }; diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js index 0da4ff49f08..5bf8216a1f3 100644 --- a/app/assets/javascripts/notes/mixins/noteable.js +++ b/app/assets/javascripts/notes/mixins/noteable.js @@ -14,6 +14,8 @@ export default { return constants.MERGE_REQUEST_NOTEABLE_TYPE; case 'Issue': return constants.ISSUE_NOTEABLE_TYPE; + case 'Epic': + return constants.EPIC_NOTEABLE_TYPE; default: return ''; } diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue index 22248418c41..2bda2aeb3a1 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue @@ -19,15 +19,19 @@ type: String, required: true, }, + groupName: { + type: String, + required: true, + }, }, computed: { title() { return sprintf(s__('Milestones|Promote %{milestoneTitle} to group milestone?'), { milestoneTitle: this.milestoneTitle }); }, text() { - return s__(`Milestones|Promoting this milestone will make it available for all projects inside the group. + return sprintf(s__(`Milestones|Promoting %{milestoneTitle} will make it available for all projects inside %{groupName}. Existing project milestones with the same title will be merged. - This action cannot be reversed.`); + This action cannot be reversed.`), { milestoneTitle: this.milestoneTitle, groupName: this.groupName }); }, }, methods: { diff --git a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js index d00f81c9094..8e79341e96a 100644 --- a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js +++ b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js @@ -25,6 +25,7 @@ export default () => { const modalProps = { milestoneTitle: button.dataset.milestoneTitle, url: button.dataset.url, + groupName: button.dataset.groupName, }; eventHub.$once('promoteMilestoneModal.requestStarted', onRequestStarted); eventHub.$emit('promoteMilestoneModal.props', modalProps); @@ -54,6 +55,7 @@ export default () => { return { modalProps: { milestoneTitle: '', + groupName: '', url: '', }, }; diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue index 54695dfeb99..ad6df51bb7a 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -1,4 +1,5 @@ <script> + import _ from 'underscore'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import GlModal from '~/vue_shared/components/gl_modal.vue'; @@ -27,19 +28,26 @@ type: String, required: true, }, + groupName: { + type: String, + required: true, + }, }, computed: { text() { - return s__(`Milestones|Promoting this label will make it available for all projects inside the group. - Existing project labels with the same title will be merged. This action cannot be reversed.`); + return sprintf(s__(`Labels|Promoting %{labelTitle} will make it available for all projects inside %{groupName}. + Existing project labels with the same title will be merged. This action cannot be reversed.`), { + labelTitle: this.labelTitle, + groupName: this.groupName, + }); }, title() { const label = `<span class="label color-label" style="background-color: ${this.labelColor}; color: ${this.labelTextColor};" - >${this.labelTitle}</span>`; + >${_.escape(this.labelTitle)}</span>`; - return sprintf(s__('Labels|Promote label %{labelTitle} to Group Label?'), { + return sprintf(s__('Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>'), { labelTitle: label, }, false); }, @@ -69,6 +77,7 @@ > <div slot="title" + class="modal-title-with-label" v-html="title" > {{ title }} diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js index 2abcbfab1ed..03cfef61311 100644 --- a/app/assets/javascripts/pages/projects/labels/index/index.js +++ b/app/assets/javascripts/pages/projects/labels/index/index.js @@ -30,6 +30,7 @@ const initLabelIndex = () => { labelColor: button.dataset.labelColor, labelTextColor: button.dataset.labelTextColor, url: button.dataset.url, + groupName: button.dataset.groupName, }; eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); eventHub.$emit('promoteLabelModal.props', modalProps); @@ -62,6 +63,7 @@ const initLabelIndex = () => { labelColor: '', labelTextColor: '', url: '', + groupName: '', }, }; }, diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 8a86c409b62..ceb02309959 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -1,59 +1,73 @@ <script> - import Flash from '../../../flash'; - import editForm from './edit_form.vue'; - import Icon from '../../../vue_shared/components/icon.vue'; - import { __ } from '../../../locale'; +import Flash from '../../../flash'; +import editForm from './edit_form.vue'; +import Icon from '../../../vue_shared/components/icon.vue'; +import { __ } from '../../../locale'; +import eventHub from '../../event_hub'; - export default { - components: { - editForm, - Icon, +export default { + components: { + editForm, + Icon, + }, + props: { + isConfidential: { + required: true, + type: Boolean, }, - props: { - isConfidential: { - required: true, - type: Boolean, - }, - isEditable: { - required: true, - type: Boolean, - }, - service: { - required: true, - type: Object, - }, + isEditable: { + required: true, + type: Boolean, }, - data() { - return { - edit: false, - }; + service: { + required: true, + type: Object, }, - computed: { - confidentialityIcon() { - return this.isConfidential ? 'eye-slash' : 'eye'; - }, + }, + data() { + return { + edit: false, + }; + }, + computed: { + confidentialityIcon() { + return this.isConfidential ? 'eye-slash' : 'eye'; }, - methods: { - toggleForm() { - this.edit = !this.edit; - }, - updateConfidentialAttribute(confidential) { - this.service.update('issue', { confidential }) - .then(() => location.reload()) - .catch(() => { - Flash(__('Something went wrong trying to change the confidentiality of this issue')); - }); - }, + }, + created() { + eventHub.$on('closeConfidentialityForm', this.toggleForm); + }, + beforeDestroy() { + eventHub.$off('closeConfidentialityForm', this.toggleForm); + }, + methods: { + toggleForm() { + this.edit = !this.edit; }, - }; + updateConfidentialAttribute(confidential) { + this.service + .update('issue', { confidential }) + .then(() => location.reload()) + .catch(() => { + Flash( + __( + 'Something went wrong trying to change the confidentiality of this issue', + ), + ); + }); + }, + }, +}; </script> <template> <div class="block issuable-sidebar-item confidentiality"> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + @click="toggleForm" + > <icon :name="confidentialityIcon" - :size="16" aria-hidden="true" /> </div> @@ -71,7 +85,6 @@ <div class="value sidebar-item-value hide-collapsed"> <editForm v-if="edit" - :toggle-form="toggleForm" :is-confidential="isConfidential" :update-confidential-attribute="updateConfidentialAttribute" /> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue index c569843b05f..3783f71a848 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -1,34 +1,34 @@ <script> - import editFormButtons from './edit_form_buttons.vue'; - import { s__ } from '../../../locale'; +import editFormButtons from './edit_form_buttons.vue'; +import { s__ } from '../../../locale'; - export default { - components: { - editFormButtons, +export default { + components: { + editFormButtons, + }, + props: { + isConfidential: { + required: true, + type: Boolean, }, - props: { - isConfidential: { - required: true, - type: Boolean, - }, - toggleForm: { - required: true, - type: Function, - }, - updateConfidentialAttribute: { - required: true, - type: Function, - }, + updateConfidentialAttribute: { + required: true, + type: Function, }, - computed: { - confidentialityOnWarning() { - return s__('confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.'); - }, - confidentialityOffWarning() { - return s__('confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.'); - }, + }, + computed: { + confidentialityOnWarning() { + return s__( + 'confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.', + ); }, - }; + confidentialityOffWarning() { + return s__( + 'confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.', + ); + }, + }, +}; </script> <template> @@ -45,7 +45,6 @@ </p> <edit-form-buttons :is-confidential="isConfidential" - :toggle-form="toggleForm" :update-confidential-attribute="updateConfidentialAttribute" /> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 49d5dfeea1a..38b1ddbfd5b 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -1,14 +1,13 @@ <script> +import $ from 'jquery'; +import eventHub from '../../event_hub'; + export default { props: { isConfidential: { required: true, type: Boolean, }, - toggleForm: { - required: true, - type: Function, - }, updateConfidentialAttribute: { required: true, type: Function, @@ -22,6 +21,16 @@ export default { return !this.isConfidential; }, }, + methods: { + closeForm() { + eventHub.$emit('closeConfidentialityForm'); + $(this.$el).trigger('hidden.gl.dropdown'); + }, + submitForm() { + this.closeForm(); + this.updateConfidentialAttribute(this.updateConfidentialBool); + }, + }, }; </script> @@ -30,14 +39,14 @@ export default { <button type="button" class="btn btn-default append-right-10" - @click="toggleForm" + @click="closeForm" > {{ __('Cancel') }} </button> <button type="button" class="btn btn-close" - @click.prevent="updateConfidentialAttribute(updateConfidentialBool)" + @click.prevent="submitForm" > {{ toggleButtonText }} </button> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue index bc32e974bc3..e1e4715826a 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -1,40 +1,43 @@ <script> - import editFormButtons from './edit_form_buttons.vue'; - import issuableMixin from '../../../vue_shared/mixins/issuable'; - import { __, sprintf } from '../../../locale'; +import editFormButtons from './edit_form_buttons.vue'; +import issuableMixin from '../../../vue_shared/mixins/issuable'; +import { __, sprintf } from '../../../locale'; - export default { - components: { - editFormButtons, +export default { + components: { + editFormButtons, + }, + mixins: [issuableMixin], + props: { + isLocked: { + required: true, + type: Boolean, }, - mixins: [ - issuableMixin, - ], - props: { - isLocked: { - required: true, - type: Boolean, - }, - toggleForm: { - required: true, - type: Function, - }, - - updateLockedAttribute: { - required: true, - type: Function, - }, + updateLockedAttribute: { + required: true, + type: Function, + }, + }, + computed: { + lockWarning() { + return sprintf( + __( + 'Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.', + ), + { issuableDisplayName: this.issuableDisplayName }, + ); }, - computed: { - lockWarning() { - return sprintf(__('Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName }); - }, - unlockWarning() { - return sprintf(__('Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName }); - }, + unlockWarning() { + return sprintf( + __( + 'Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.', + ), + { issuableDisplayName: this.issuableDisplayName }, + ); }, - }; + }, +}; </script> <template> @@ -54,7 +57,6 @@ <edit-form-buttons :is-locked="isLocked" - :toggle-form="toggleForm" :update-locked-attribute="updateLockedAttribute" /> </div> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index c3a553a7605..5e7b8f9698f 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -1,4 +1,7 @@ <script> +import $ from 'jquery'; +import eventHub from '../../event_hub'; + export default { props: { isLocked: { @@ -6,11 +9,6 @@ export default { type: Boolean, }, - toggleForm: { - required: true, - type: Function, - }, - updateLockedAttribute: { required: true, type: Function, @@ -26,6 +24,17 @@ export default { return !this.isLocked; }, }, + + methods: { + closeForm() { + eventHub.$emit('closeLockForm'); + $(this.$el).trigger('hidden.gl.dropdown'); + }, + submitForm() { + this.closeForm(); + this.updateLockedAttribute(this.toggleLock); + }, + }, }; </script> @@ -34,7 +43,7 @@ export default { <button type="button" class="btn btn-default append-right-10" - @click="toggleForm" + @click="closeForm" > {{ __('Cancel') }} </button> @@ -42,7 +51,7 @@ export default { <button type="button" class="btn btn-close" - @click.prevent="updateLockedAttribute(toggleLock)" + @click.prevent="submitForm" > {{ buttonText }} </button> diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index 0686910fc7e..e4893451af3 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -1,70 +1,93 @@ <script> - import Flash from '~/flash'; - import editForm from './edit_form.vue'; - import issuableMixin from '../../../vue_shared/mixins/issuable'; - import Icon from '../../../vue_shared/components/icon.vue'; +import Flash from '~/flash'; +import editForm from './edit_form.vue'; +import issuableMixin from '../../../vue_shared/mixins/issuable'; +import Icon from '../../../vue_shared/components/icon.vue'; +import eventHub from '../../event_hub'; - export default { - components: { - editForm, - Icon, - }, - mixins: [ - issuableMixin, - ], +export default { + components: { + editForm, + Icon, + }, + mixins: [issuableMixin], - props: { - isLocked: { - required: true, - type: Boolean, - }, + props: { + isLocked: { + required: true, + type: Boolean, + }, - isEditable: { - required: true, - type: Boolean, - }, + isEditable: { + required: true, + type: Boolean, + }, - mediator: { - required: true, - type: Object, - validator(mediatorObject) { - return mediatorObject.service && mediatorObject.service.update && mediatorObject.store; - }, + mediator: { + required: true, + type: Object, + validator(mediatorObject) { + return ( + mediatorObject.service && + mediatorObject.service.update && + mediatorObject.store + ); }, }, + }, - computed: { - lockIcon() { - return this.isLocked ? 'lock' : 'lock-open'; - }, + computed: { + lockIcon() { + return this.isLocked ? 'lock' : 'lock-open'; + }, - isLockDialogOpen() { - return this.mediator.store.isLockDialogOpen; - }, + isLockDialogOpen() { + return this.mediator.store.isLockDialogOpen; }, + }, - methods: { - toggleForm() { - this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen; - }, + created() { + eventHub.$on('closeLockForm', this.toggleForm); + }, + + beforeDestroy() { + eventHub.$off('closeLockForm', this.toggleForm); + }, - updateLockedAttribute(locked) { - this.mediator.service.update(this.issuableType, { + methods: { + toggleForm() { + this.mediator.store.isLockDialogOpen = !this.mediator.store + .isLockDialogOpen; + }, + + updateLockedAttribute(locked) { + this.mediator.service + .update(this.issuableType, { discussion_locked: locked, }) .then(() => location.reload()) - .catch(() => Flash(this.__(`Something went wrong trying to change the locked state of this ${this.issuableDisplayName}`))); - }, + .catch(() => + Flash( + this.__( + `Something went wrong trying to change the locked state of this ${ + this.issuableDisplayName + }`, + ), + ), + ); }, - }; + }, +}; </script> <template> <div class="block issuable-sidebar-item lock"> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + @click="toggleForm" + > <icon :name="lockIcon" - :size="16" aria-hidden="true" class="sidebar-item-icon is-active" /> @@ -85,7 +108,6 @@ <div class="value sidebar-item-value hide-collapsed"> <edit-form v-if="isLockDialogOpen" - :toggle-form="toggleForm" :is-locked="isLocked" :update-locked-attribute="updateLockedAttribute" :issuable-type="issuableType" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 3d886e7d628..18ee4c62bf1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -1,53 +1,57 @@ <script> - import tooltip from '~/vue_shared/directives/tooltip'; - import { n__ } from '~/locale'; - import icon from '~/vue_shared/components/icon.vue'; - import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { n__ } from '~/locale'; +import { webIDEUrl } from '~/lib/utils/url_utility'; +import icon from '~/vue_shared/components/icon.vue'; +import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; - export default { - name: 'MRWidgetHeader', - directives: { - tooltip, +export default { + name: 'MRWidgetHeader', + directives: { + tooltip, + }, + components: { + icon, + clipboardButton, + }, + props: { + mr: { + type: Object, + required: true, }, - components: { - icon, - clipboardButton, + }, + computed: { + shouldShowCommitsBehindText() { + return this.mr.divergedCommitsCount > 0; }, - props: { - mr: { - type: Object, - required: true, - }, + commitsText() { + return n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount); }, - computed: { - shouldShowCommitsBehindText() { - return this.mr.divergedCommitsCount > 0; - }, - commitsText() { - return n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount); - }, - branchNameClipboardData() { - // This supports code in app/assets/javascripts/copy_to_clipboard.js that - // works around ClipboardJS limitations to allow the context-specific - // copy/pasting of plain text or GFM. - return JSON.stringify({ - text: this.mr.sourceBranch, - gfm: `\`${this.mr.sourceBranch}\``, - }); - }, - isSourceBranchLong() { - return this.isBranchTitleLong(this.mr.sourceBranch); - }, - isTargetBranchLong() { - return this.isBranchTitleLong(this.mr.targetBranch); - }, + branchNameClipboardData() { + // This supports code in app/assets/javascripts/copy_to_clipboard.js that + // works around ClipboardJS limitations to allow the context-specific + // copy/pasting of plain text or GFM. + return JSON.stringify({ + text: this.mr.sourceBranch, + gfm: `\`${this.mr.sourceBranch}\``, + }); }, - methods: { - isBranchTitleLong(branchTitle) { - return branchTitle.length > 32; - }, + isSourceBranchLong() { + return this.isBranchTitleLong(this.mr.sourceBranch); }, - }; + isTargetBranchLong() { + return this.isBranchTitleLong(this.mr.targetBranch); + }, + webIdePath() { + return webIDEUrl(this.mr.statusPath.replace('.json', '')); + }, + }, + methods: { + isBranchTitleLong(branchTitle) { + return branchTitle.length > 32; + }, + }, +}; </script> <template> <div class="mr-source-target"> @@ -96,6 +100,13 @@ </div> <div v-if="mr.isOpen"> + <a + v-if="!mr.sourceBranchRemoved" + :href="webIdePath" + class="btn btn-sm btn-default inline js-web-ide" + > + {{ s__("mrWidget|Web IDE") }} + </a> <button data-target="#modal_merge_info" data-toggle="modal" diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index 7f3f7e67d76..05cb0196ced 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -199,6 +199,10 @@ .branch-header-title { color: $color-700; } + + .ide-file-list .file.file-active { + color: $color-700; + } } body { diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 48b981dd31f..eb789cc64b0 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -4,9 +4,15 @@ .page-title, .modal-title { + .modal-title-with-label span { + vertical-align: middle; + display: inline-block; + } + .color-label { font-size: $gl-font-size; padding: $gl-vert-padding $label-padding-modal; + vertical-align: middle; } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 3dd4a613789..798f248dad4 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -88,7 +88,6 @@ .right-sidebar { border-left: 1px solid $border-color; - height: calc(100% - #{$header-height}); } .with-performance-bar .right-sidebar.affix { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index e21a9f0afc9..2c0ed976301 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -522,10 +522,6 @@ .with-performance-bar .right-sidebar { top: $header-height + $performance-bar-height; - - .issuable-sidebar { - height: calc(100% - #{$performance-bar-height}); - } } .sidebar-move-issue-confirmation-button { diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 42772f13155..ce2f1482456 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -706,8 +706,8 @@ button.mini-pipeline-graph-dropdown-toggle { // dropdown content for big and mini pipeline .big-pipeline-graph-dropdown-menu, .mini-pipeline-graph-dropdown-menu { - width: 195px; - max-width: 195px; + width: 240px; + max-width: 240px; .scrollable-menu { padding: 0; @@ -750,7 +750,7 @@ button.mini-pipeline-graph-dropdown-toggle { height: #{$ci-action-icon-size - 6}; left: -3px; position: relative; - top: -2px; + top: -1px; &.icon-action-stop, &.icon-action-cancel { @@ -931,13 +931,11 @@ button.mini-pipeline-graph-dropdown-toggle { */ &.dropdown-menu { transform: translate(-80%, 0); - min-width: 150px; @media(min-width: $screen-md-min) { transform: translate(-50%, 0); right: auto; left: 50%; - min-width: 240px; } } } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 65046f6665e..1f6f7138e1f 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -19,8 +19,7 @@ .ide-view { display: flex; height: calc(100vh - #{$header-height}); - margin-top: 40px; - color: $almost-black; + margin-top: 0; border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; @@ -43,13 +42,18 @@ cursor: pointer; &.file-open { - background: $white-normal; + background: $link-active-background; + } + + &.file-active { + font-weight: $gl-font-weight-bold; } .ide-file-name { flex: 1; white-space: nowrap; text-overflow: ellipsis; + max-width: inherit; svg { vertical-align: middle; @@ -72,7 +76,10 @@ margin-right: -8px; } - &:hover { + &:hover, + &:focus { + background: $link-active-background; + .ide-new-btn { display: block; } @@ -450,6 +457,8 @@ display: flex; flex-direction: column; flex: 1; + max-height: 100%; + overflow: auto; } .multi-file-commit-empty-state-container { @@ -460,7 +469,7 @@ .multi-file-commit-panel-header { display: flex; align-items: center; - margin-bottom: 12px; + margin-bottom: 0; border-bottom: 1px solid $white-dark; padding: $gl-btn-padding 0; @@ -667,8 +676,14 @@ overflow: hidden; &.nav-only { + padding-top: $header-height; + + .with-performance-bar & { + padding-top: $header-height + $performance-bar-height; + } + .flash-container { - margin-top: $header-height; + margin-top: 0; margin-bottom: 0; } @@ -678,7 +693,7 @@ } .content-wrapper { - margin-top: $header-height; + margin-top: 0; padding-bottom: 0; } @@ -702,11 +717,11 @@ .with-performance-bar .ide.nav-only { .flash-container { - margin-top: #{$header-height + $performance-bar-height}; + margin-top: 0; } .content-wrapper { - margin-top: #{$header-height + $performance-bar-height}; + margin-top: 0; padding-bottom: 0; } @@ -715,14 +730,8 @@ } &.flash-shown { - .content-wrapper { - margin-top: 0; - } - .ide-view { - height: calc( - 100vh - #{$header-height + $performance-bar-height + $flash-height} - ); + height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height}); } } } diff --git a/app/assets/stylesheets/pages/repo.scss.orig b/app/assets/stylesheets/pages/repo.scss.orig new file mode 100644 index 00000000000..57b995adb64 --- /dev/null +++ b/app/assets/stylesheets/pages/repo.scss.orig @@ -0,0 +1,786 @@ +.project-refs-form, +.project-refs-target-form { + display: inline-block; +} + +.fade-enter, +.fade-leave-to { + opacity: 0; +} + +.commit-message { + @include str-truncated(250px); +} + +.editable-mode { + display: inline-block; +} + +.ide-view { + display: flex; + height: calc(100vh - #{$header-height}); + margin-top: 40px; + color: $almost-black; + border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + + &.is-collapsed { + .ide-file-list { + max-width: 250px; + } + } + + .file-status-icon { + width: 10px; + height: 10px; + } +} + +.ide-file-list { + flex: 1; + + .file { + cursor: pointer; + + &.file-open { + background: $white-normal; + } + + .ide-file-name { + flex: 1; + white-space: nowrap; + text-overflow: ellipsis; + + svg { + vertical-align: middle; + margin-right: 2px; + } + + .loading-container { + margin-right: 4px; + display: inline-block; + } + } + + .ide-file-changed-icon { + margin-left: auto; + } + + .ide-new-btn { + display: none; + margin-bottom: -4px; + margin-right: -8px; + } + + &:hover { + .ide-new-btn { + display: block; + } + } + + &.folder { + svg { + fill: $gl-text-color-secondary; + } + } + } + + a { + color: $gl-text-color; + } + + th { + position: sticky; + top: 0; + } +} + +.file-name, +.file-col-commit-message { + display: flex; + overflow: visible; + padding: 6px 12px; +} + +.multi-file-loading-container { + margin-top: 10px; + padding: 10px; + + .animation-container { + background: $gray-light; + + div { + background: $gray-light; + } + } +} + +.multi-file-table-col-commit-message { + white-space: nowrap; + width: 50%; +} + +.multi-file-edit-pane { + display: flex; + flex-direction: column; + flex: 1; + border-left: 1px solid $white-dark; + overflow: hidden; +} + +.multi-file-tabs { + display: flex; + background-color: $white-normal; + box-shadow: inset 0 -1px $white-dark; + + > ul { + display: flex; + overflow-x: auto; + } + + li { + position: relative; + } + + .dropdown { + display: flex; + margin-left: auto; + margin-bottom: 1px; + padding: 0 $grid-size; + border-left: 1px solid $white-dark; + background-color: $white-light; + + &.shadow { + box-shadow: 0 0 10px $dropdown-shadow-color; + } + + .btn { + margin-top: auto; + margin-bottom: auto; + } + } +} + +.multi-file-tab { + @include str-truncated(150px); + padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding; + background-color: $gray-normal; + border-right: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + cursor: pointer; + + svg { + vertical-align: middle; + } + + &.active { + background-color: $white-light; + border-bottom-color: $white-light; + } +} + +.multi-file-tab-close { + position: absolute; + right: 8px; + top: 50%; + width: 16px; + height: 16px; + padding: 0; + background: none; + border: 0; + border-radius: $border-radius-default; + color: $theme-gray-900; + transform: translateY(-50%); + + svg { + position: relative; + top: -1px; + } + + &:hover { + background-color: $theme-gray-200; + } + + &:focus { + background-color: $blue-500; + color: $white-light; + outline: 0; + + svg { + fill: currentColor; + } + } +} + +.multi-file-edit-pane-content { + flex: 1; + height: 0; +} + +.blob-editor-container { + flex: 1; + height: 0; + display: flex; + flex-direction: column; + justify-content: center; + + .vertical-center { + min-height: auto; + } + + .monaco-editor .lines-content .cigr { + display: none; + } + + .monaco-diff-editor.vs { + .editor.modified { + box-shadow: none; + } + + .diagonal-fill { + display: none !important; + } + + .diffOverview { + background-color: $white-light; + border-left: 1px solid $white-dark; + cursor: ns-resize; + } + + .diffViewport { + display: none; + } + + .char-insert { + background-color: $line-added-dark; + } + + .char-delete { + background-color: $line-removed-dark; + } + + .line-numbers { + color: $black-transparent; + } + + .view-overlays { + .line-insert { + background-color: $line-added; + } + + .line-delete { + background-color: $line-removed; + } + } + + .margin { + background-color: $gray-light; + border-right: 1px solid $white-normal; + + .line-insert { + border-right: 1px solid $line-added-dark; + } + + .line-delete { + border-right: 1px solid $line-removed-dark; + } + } + + .margin-view-overlays .insert-sign, + .margin-view-overlays .delete-sign { + opacity: 0.4; + } + + .cursors-layer { + display: none; + } + } +} + +.multi-file-editor-holder { + height: 100%; +} + +.multi-file-editor-btn-group { + padding: $gl-bar-padding $gl-padding; + border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + background: $white-light; +} + +.ide-status-bar { + padding: $gl-bar-padding $gl-padding; + background: $white-light; + display: flex; + justify-content: space-between; + + svg { + vertical-align: middle; + } +} + +// Not great, but this is to deal with our current output +.multi-file-preview-holder { + height: 100%; + overflow: scroll; + + .file-content.code { + display: flex; + + i { + margin-left: -10px; + } + } + + .line-numbers { + min-width: 50px; + } + + .file-content, + .line-numbers, + .blob-content, + .code { + min-height: 100%; + } +} + +.file-content.blob-no-preview { + a { + margin-left: auto; + margin-right: auto; + } +} + +.multi-file-commit-panel { + display: flex; + position: relative; + flex-direction: column; + width: 340px; + padding: 0; + background-color: $gray-light; + padding-right: 3px; + + .projects-sidebar { + display: flex; + flex-direction: column; + + .context-header { + width: auto; + margin-right: 0; + } + } + + .multi-file-commit-panel-inner { + display: flex; + flex: 1; + flex-direction: column; + } + + .multi-file-commit-panel-inner-scroll { + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; + } + + &.is-collapsed { + width: 60px; + + .multi-file-commit-list { + padding-top: $gl-padding; + overflow: hidden; + } + + .multi-file-context-bar-icon { + align-items: center; + + svg { + float: none; + margin: 0; + } + } + } + + .branch-container { + border-left: 4px solid $indigo-700; + margin-bottom: $gl-bar-padding; + } + + .branch-header { + background: $white-dark; + display: flex; + } + + .branch-header-title { + flex: 1; + padding: $grid-size $gl-padding; + color: $indigo-700; + font-weight: $gl-font-weight-bold; + + svg { + vertical-align: middle; + } + } + + .branch-header-btns { + padding: $gl-vert-padding $gl-padding; + } + + .left-collapse-btn { + display: none; + background: $gray-light; + text-align: left; + border-top: 1px solid $white-dark; + + svg { + vertical-align: middle; + } + } +} + +.multi-file-context-bar-icon { + padding: 10px; + + svg { + margin-right: 10px; + float: left; + } +} + +.multi-file-commit-panel-section { + display: flex; + flex-direction: column; + flex: 1; +} + +.multi-file-commit-empty-state-container { + align-items: center; + justify-content: center; +} + +.multi-file-commit-panel-header { + display: flex; + align-items: center; + margin-bottom: 12px; + border-bottom: 1px solid $white-dark; + padding: $gl-btn-padding 0; + + &.is-collapsed { + border-bottom: 1px solid $white-dark; + + svg { + margin-left: auto; + margin-right: auto; + } + + .multi-file-commit-panel-collapse-btn { + margin-right: auto; + margin-left: auto; + border-left: 0; + } + } +} + +.multi-file-commit-panel-header-title { + display: flex; + flex: 1; + padding: 0 $gl-btn-padding; + + svg { + margin-right: $gl-btn-padding; + } +} + +.multi-file-commit-panel-collapse-btn { + border-left: 1px solid $white-dark; +} + +.multi-file-commit-list { + flex: 1; + overflow: auto; + padding: $gl-padding 0; + min-height: 60px; +} + +.multi-file-commit-list-item { + display: flex; + padding: 0; + align-items: center; + + .multi-file-discard-btn { + display: none; + margin-left: auto; + color: $gl-link-color; + padding: 0 2px; + + &:focus, + &:hover { + text-decoration: underline; + } + } + + &:hover { + background: $white-normal; + + .multi-file-discard-btn { + display: block; + } + } +} + +.multi-file-addition { + fill: $green-500; +} + +.multi-file-modified { + fill: $orange-500; +} + +.multi-file-commit-list-collapsed { + display: flex; + flex-direction: column; + + > svg { + margin-left: auto; + margin-right: auto; + } + + .file-status-icon { + width: 10px; + height: 10px; + margin-left: 3px; + } +} + +.multi-file-commit-list-path { + padding: $grid-size / 2; + padding-left: $gl-padding; + background: none; + border: 0; + text-align: left; + width: 100%; + min-width: 0; + + svg { + min-width: 16px; + vertical-align: middle; + display: inline-block; + } + + &:hover, + &:focus { + outline: 0; + } +} + +.multi-file-commit-list-file-path { + @include str-truncated(100%); + + &:hover { + text-decoration: underline; + } + + &:active { + text-decoration: none; + } +} + +.multi-file-commit-form { + padding: $gl-padding; + border-top: 1px solid $white-dark; + + .btn { + font-size: $gl-font-size; + } +} + +.multi-file-commit-message.form-control { + height: 160px; + resize: none; +} + +.dirty-diff { + // !important need to override monaco inline style + width: 4px !important; + left: 0 !important; + + &-modified { + background-color: $blue-500; + } + + &-added { + background-color: $green-600; + } + + &-removed { + height: 0 !important; + width: 0 !important; + bottom: -2px; + border-style: solid; + border-width: 5px; + border-color: transparent transparent transparent $red-500; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100px; + height: 1px; + background-color: rgba($red-500, 0.5); + } + } +} + +.ide-loading { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.ide-empty-state { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.ide-new-btn { + .dropdown-toggle svg { + margin-top: -2px; + margin-bottom: 2px; + } + + .dropdown-menu { + left: auto; + right: 0; + + label { + font-weight: $gl-font-weight-normal; + padding: 5px 8px; + margin-bottom: 0; + } + } +} + +.ide { + overflow: hidden; + + &.nav-only { + .flash-container { + margin-top: $header-height; + margin-bottom: 0; + } + + .alert-wrapper .flash-container .flash-alert:last-child, + .alert-wrapper .flash-container .flash-notice:last-child { + margin-bottom: 0; + } + + .content-wrapper { + margin-top: $header-height; + padding-bottom: 0; + } + + &.flash-shown { + .content-wrapper { + margin-top: 0; + } + + .ide-view { + height: calc(100vh - #{$header-height + $flash-height}); + } + } + + .projects-sidebar { + .multi-file-commit-panel-inner-scroll { + flex: 1; + } + } + } +} + +.with-performance-bar .ide.nav-only { + .flash-container { + margin-top: #{$header-height + $performance-bar-height}; + } + + .content-wrapper { + margin-top: #{$header-height + $performance-bar-height}; + padding-bottom: 0; + } + + .ide-view { + height: calc(100vh - #{$header-height + $performance-bar-height}); + } + + &.flash-shown { + .content-wrapper { + margin-top: 0; + } + + .ide-view { + height: calc( + 100vh - #{$header-height + $performance-bar-height + $flash-height} + ); + } + } +} + +.dragHandle { + position: absolute; + top: 0; + bottom: 0; + width: 3px; + background-color: $white-dark; + + &.dragright { + right: 0; + } + + &.dragleft { + left: 0; + } +} + +.ide-commit-radios { + label { + font-weight: normal; + } + + .help-block { + margin-top: 0; + line-height: 0; + } +} + +.ide-commit-new-branch { + margin-left: 25px; +} + +.ide-external-links { + p { + margin: 0; + } +} + +.ide-sidebar-link { + padding: $gl-padding-8 $gl-padding; + background: $indigo-700; + color: $white-light; + text-decoration: none; + display: flex; + align-items: center; + + &:focus, + &:hover { + color: $white-light; + text-decoration: underline; + background: $indigo-500; + } + + &:active { + background: $indigo-800; + } +} diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb index dd0b38970bd..ea302f17d16 100644 --- a/app/controllers/admin/appearances_controller.rb +++ b/app/controllers/admin/appearances_controller.rb @@ -50,9 +50,19 @@ class Admin::AppearancesController < Admin::ApplicationController # Only allow a trusted parameter "white list" through. def appearance_params - params.require(:appearance).permit( - :title, :description, :logo, :logo_cache, :header_logo, :header_logo_cache, - :new_project_guidelines, :updated_by - ) + params.require(:appearance).permit(allowed_appearance_params) + end + + def allowed_appearance_params + %i[ + title + description + logo + logo_cache + header_logo + header_logo_cache + new_project_guidelines + updated_by + ] end end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index cc38608eda5..001f6520093 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -5,7 +5,7 @@ class Admin::GroupsController < Admin::ApplicationController def index @groups = Group.with_statistics.with_route - @groups = @groups.sort(@sort = params[:sort]) + @groups = @groups.sort_by_attribute(@sort = params[:sort]) @groups = @groups.search(params[:name]) if params[:name].present? @groups = @groups.page(params[:page]) end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 156a8e2c515..bfeb5a2d097 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -4,7 +4,7 @@ class Admin::UsersController < Admin::ApplicationController def index @users = User.order_name_asc.filter(params[:filter]) @users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present? - @users = @users.sort(@sort = params[:sort]) + @users = @users.sort_by_attribute(@sort = params[:sort]) @users = @users.page(params[:page]) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7f83bd10e93..24651dd392c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -229,10 +229,6 @@ class ApplicationController < ActionController::Base @event_filter ||= EventFilter.new(filters) end - def gitlab_ldap_access(&block) - Gitlab::Auth::LDAP::Access.open { |access| yield(access) } - end - # JSON for infinite scroll via Pager object def pager_json(partial, count, locals = {}) html = render_to_string( diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb index fafb10090ca..56770a17406 100644 --- a/app/controllers/concerns/group_tree.rb +++ b/app/controllers/concerns/group_tree.rb @@ -14,7 +14,7 @@ module GroupTree end @groups = @groups.with_selects_for_list(archived: params[:archived]) - .sort(@sort = params[:sort]) + .sort_by_attribute(@sort = params[:sort]) .page(params[:page]) respond_to do |format| diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index a21e658fda1..0379f76fc3d 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -88,11 +88,15 @@ module IssuableActions discussions = Discussion.build_collection(notes, issuable) - render json: DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user).represent(discussions, context: self) + render json: discussion_serializer.represent(discussions, context: self) end private + def discussion_serializer + DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user, note_entity: ProjectNoteEntity) + end + def recaptcha_check_if_spammable(should_redirect = true, &block) return yield unless issuable.is_a? Spammable diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 03ed5b5310b..839cac3687c 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -212,7 +212,7 @@ module NotesActions end def note_serializer - NoteSerializer.new(project: project, noteable: noteable, current_user: current_user) + ProjectNoteSerializer.new(project: project, noteable: noteable, current_user: current_user) end def note_project diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index f210434b2d7..134b0dfc0db 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -17,7 +17,7 @@ class Groups::GroupMembersController < Groups::ApplicationController @members = GroupMembersFinder.new(@group).execute @members = @members.non_invite unless can?(current_user, :admin_group, @group) @members = @members.search(params[:search]) if params[:search].present? - @members = @members.sort(@sort) + @members = @members.sort_by_attribute(@sort) @members = @members.page(params[:page]).per(50) @members = present_members(@members.includes(:user)) diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index dbf61a17724..3d27ae18b17 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -51,7 +51,7 @@ class ProfilesController < Profiles::ApplicationController end def update_username - result = Users::UpdateService.new(current_user, user: @user, username: user_params[:username]).execute + result = Users::UpdateService.new(current_user, user: @user, username: username_param).execute options = if result[:status] == :success { notice: "Username successfully changed" } @@ -72,6 +72,10 @@ class ProfilesController < Profiles::ApplicationController return render_404 unless @user.can_change_username? end + def username_param + @username_param ||= user_params.require(:username) + end + def user_params @user_params ||= params.require(:user).permit( :avatar, diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 965cece600e..b7b36f770f5 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -21,17 +21,17 @@ class Projects::BranchesController < Projects::ApplicationController fetch_branches_by_mode @refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name)) - @merged_branch_names = - repository.merged_branch_names(@branches.map(&:name)) - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37429 + @merged_branch_names = repository.merged_branch_names(@branches.map(&:name)) + + # n+1: https://gitlab.com/gitlab-org/gitaly/issues/992 Gitlab::GitalyClient.allow_n_plus_1_calls do @max_commits = @branches.reduce(0) do |memo, branch| diverging_commit_counts = repository.diverging_commit_counts(branch) [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max end - - render end + + render end format.json do branches = BranchesFinder.new(@repository, params).execute diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index cba9a53dc4b..7bc16214010 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -43,7 +43,7 @@ class Projects::DiscussionsController < Projects::ApplicationController def render_json_with_discussions_serializer render json: - DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user) + DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity) .represent(discussion, context: self) end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 99790b8e7e8..516198b1b8a 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -112,7 +112,7 @@ class Projects::LabelsController < Projects::ApplicationController begin return render_404 unless promote_service.execute(@label) - flash[:notice] = "#{@label.title} promoted to group label." + flash[:notice] = "#{@label.title} promoted to <a href=\"#{group_labels_path(@project.group)}\">group label</a>.".html_safe respond_to do |format| format.html do redirect_to(project_labels_path(@project), status: 303) diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index cf84629fadc..c5a044541f1 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -14,7 +14,7 @@ class Projects::MilestonesController < Projects::ApplicationController def index @sort = params[:sort] || 'due_date_asc' - @milestones = milestones.sort(@sort) + @milestones = milestones.sort_by_attribute(@sort) respond_to do |format| format.html do @@ -74,9 +74,9 @@ class Projects::MilestonesController < Projects::ApplicationController end def promote - Milestones::PromoteService.new(project, current_user).execute(milestone) + promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) - flash[:notice] = "#{milestone.title} promoted to group milestone" + flash[:notice] = "#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, promoted_milestone.iid)}\">group milestone</a>.".html_safe respond_to do |format| format.html do redirect_to project_milestones_path(project) diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index e9b4679f94c..cfa5e72af64 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -21,7 +21,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController @group_links = @group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id)) end - @project_members = present_members(@project_members.sort(@sort).page(params[:page])) + @project_members = present_members(@project_members.sort_by_attribute(@sort).page(params[:page])) @requesters = present_members(AccessRequestsFinder.new(@project).execute(current_user)) @project_member = @project.project_members.new end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index f14cb5f6a9f..a5ea9ff7ed7 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -46,6 +46,8 @@ class Projects::ServicesController < Projects::ApplicationController else { error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(',') } end + rescue Gitlab::HTTP::BlockedUrlError => e + { error: true, message: 'Test failed.', service_response: e.message } end def success_message diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index d06d18c498b..dd9e4a2af3e 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -16,6 +16,10 @@ module Projects @protected_tags = @project.protected_tags.order(:name).page(params[:page]) @protected_branch = @project.protected_branches.new @protected_tag = @project.protected_tags.new + + @protected_branches_count = @protected_branches.reduce(0) { |sum, branch| sum + branch.matching(@project.repository.branches).size } + @protected_tags_count = @protected_tags.reduce(0) { |sum, tag| sum + tag.matching(@project.repository.tags).size } + load_gon_index end diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb index 2c8f21c2400..53b77f5fed9 100644 --- a/app/finders/admin/projects_finder.rb +++ b/app/finders/admin/projects_finder.rb @@ -62,6 +62,6 @@ class Admin::ProjectsFinder def sort(items) sort = params.fetch(:sort) { 'latest_activity_desc' } - items.sort(sort) + items.sort_by_attribute(sort) end end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index b2d4f9938ff..61c72aa22a8 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -337,7 +337,7 @@ class IssuableFinder def sort(items) # Ensure we always have an explicit sort order (instead of inheriting # multiple orders when combining ActiveRecord::Relation objects). - params[:sort] ? items.sort(params[:sort], excluded_labels: label_names) : items.reorder(id: :desc) + params[:sort] ? items.sort_by_attribute(params[:sort], excluded_labels: label_names) : items.reorder(id: :desc) end def by_assignee(items) diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 005612ededc..c7d6bc6cfdc 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -124,7 +124,7 @@ class ProjectsFinder < UnionFinder end def sort(items) - params[:sort].present? ? items.sort(params[:sort]) : items.order_id_desc + params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.order_id_desc end def by_archived(projects) diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 150f4c7688b..09e2c586f2a 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -119,7 +119,7 @@ class TodosFinder end def sort(items) - params[:sort] ? items.sort(params[:sort]) : items.order_id_desc + params[:sort] ? items.sort_by_attribute(params[:sort]) : items.order_id_desc end def by_action(items) diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index c037de33c22..f48db024e3f 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -1,27 +1,27 @@ module AppearancesHelper def brand_title - brand_item&.title.presence || 'GitLab Community Edition' + current_appearance&.title.presence || 'GitLab Community Edition' end def brand_image - image_tag(brand_item.logo) if brand_item&.logo? + image_tag(current_appearance.logo) if current_appearance&.logo? end def brand_text - markdown_field(brand_item, :description) + markdown_field(current_appearance, :description) end def brand_new_project_guidelines - markdown_field(brand_item, :new_project_guidelines) + markdown_field(current_appearance, :new_project_guidelines) end - def brand_item + def current_appearance @appearance ||= Appearance.current end def brand_header_logo - if brand_item&.header_logo? - image_tag brand_item.header_logo + if current_appearance&.header_logo? + image_tag current_appearance.header_logo else render 'shared/logo.svg' end @@ -29,7 +29,7 @@ module AppearancesHelper # Skip the 'GitLab' type logo when custom brand logo is set def brand_header_logo_type - unless brand_item&.header_logo? + unless current_appearance&.header_logo? render 'shared/logo_type.svg' end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 701be97ee96..86ec500ceb3 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -285,6 +285,10 @@ module ApplicationHelper class_names end + # EE feature: System header and footer, unavailable in CE + def system_message_class + end + # Returns active css class when condition returns true # otherwise returns nil. # diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 4ddc1dbed49..c86a26ac30f 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -54,9 +54,9 @@ module EmailsHelper end def header_logo - if brand_item && brand_item.header_logo? + if current_appearance&.header_logo? image_tag( - brand_item.header_logo, + current_appearance.header_logo, style: 'height: 50px' ) else diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 40ca666f1bf..9be93fa69ae 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -31,7 +31,7 @@ module NamespacesHelper def namespace_icon(namespace, size = 40) if namespace.is_a?(Group) - group_icon(namespace) + group_icon_url(namespace) else avatar_icon_for_user(namespace.owner, size) end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 20aed60cb7a..27ed48fdbc7 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -151,16 +151,17 @@ module NotesHelper } end - def notes_data(issuable) - discussions_path = - if issuable.is_a?(Issue) - discussions_project_issue_path(@project, issuable, format: :json) - else - discussions_project_merge_request_path(@project, issuable, format: :json) - end + def discussions_path(issuable) + if issuable.is_a?(Issue) + discussions_project_issue_path(@project, issuable, format: :json) + else + discussions_project_merge_request_path(@project, issuable, format: :json) + end + end + def notes_data(issuable) { - discussionsPath: discussions_path, + discussionsPath: discussions_path(issuable), registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), markdownDocsPath: help_page_path('user/markdown'), @@ -170,7 +171,6 @@ module NotesHelper notesPath: notes_url, totalNotes: issuable.discussions.length, lastFetchedAt: Time.now.to_i - }.to_json end diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index 18b9bf214a3..a8397b03d63 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -39,7 +39,10 @@ module PageLayoutHelper end def favicon - Rails.env.development? ? 'favicon-blue.ico' : 'favicon.ico' + return 'favicon-yellow.ico' if Gitlab::Utils.to_boolean(ENV['CANARY']) + return 'favicon-blue.ico' if Rails.env.development? + + 'favicon.ico' end def page_image diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index be99f3780cc..b3f2aeb08ca 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -15,6 +15,7 @@ module Emails setup_merge_request_mail(merge_request_id, recipient_id) @new_commits = new_commits @existing_commits = existing_commits + @updated_by_user = User.find(updated_by_user_id) mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) end diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb index ec56cc53aea..760f01f225b 100644 --- a/app/models/ci/artifact_blob.rb +++ b/app/models/ci/artifact_blob.rb @@ -36,16 +36,15 @@ module Ci def external_url(project, job) return unless external_link?(job) - full_path_parts = project.full_path_components - top_level_group = full_path_parts.shift + url_project_path = project.full_path.partition('/').last artifact_path = [ - '-', *full_path_parts, '-', + '-', url_project_path, '-', 'jobs', job.id, 'artifacts', path ].join('/') - "#{pages_config.protocol}://#{top_level_group}.#{pages_config.host}/#{artifact_path}" + "#{project.pages_group_url}/#{artifact_path}" end def external_link?(job) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 08bb5915d10..18e96389199 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -6,6 +6,7 @@ module Ci include ObjectStorage::BackgroundMove include Presentable include Importable + include Gitlab::Utils::StrongMemoize MissingDependenciesError = Class.new(StandardError) @@ -24,12 +25,18 @@ module Ci has_one :job_artifacts_metadata, -> { where(file_type: Ci::JobArtifact.file_types[:metadata]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id has_one :job_artifacts_trace, -> { where(file_type: Ci::JobArtifact.file_types[:trace]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id - # The "environment" field for builds is a String, and is the unexpanded name + has_one :metadata, class_name: 'Ci::BuildMetadata' + delegate :timeout, to: :metadata, prefix: true, allow_nil: true + + ## + # The "environment" field for builds is a String, and is the unexpanded name! + # def persisted_environment - @persisted_environment ||= Environment.find_by( - name: expanded_environment_name, - project: project - ) + return unless has_environment? + + strong_memoize(:persisted_environment) do + Environment.find_by(name: expanded_environment_name, project: project) + end end serialize :options # rubocop:disable Cop/ActiveRecordSerialize @@ -153,6 +160,14 @@ module Ci before_transition any => [:running] do |build| build.validates_dependencies! unless Feature.enabled?('ci_disable_validates_dependencies') end + + before_transition pending: :running do |build| + build.ensure_metadata.update_timeout_state + end + end + + def ensure_metadata + metadata || build_metadata(project: project) end def detailed_status(current_user) @@ -200,7 +215,11 @@ module Ci end def expanded_environment_name - ExpandVariables.expand(environment, simple_variables) if environment + return unless has_environment? + + strong_memoize(:expanded_environment_name) do + ExpandVariables.expand(environment, simple_variables) + end end def has_environment? @@ -231,10 +250,6 @@ module Ci latest_builds.where('stage_idx < ?', stage_idx) end - def timeout - project.build_timeout - end - def triggered_by?(current_user) user == current_user end @@ -250,31 +265,52 @@ module Ci Gitlab::Utils.slugify(ref.to_s) end - # Variables whose value does not depend on environment - def simple_variables - variables(environment: nil) - end - - # All variables, including those dependent on environment, which could - # contain unexpanded variables. - def variables(environment: persisted_environment) - collection = Gitlab::Ci::Variables::Collection.new.tap do |variables| + ## + # Variables in the environment name scope. + # + def scoped_variables(environment: expanded_environment_name) + Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.concat(predefined_variables) variables.concat(project.predefined_variables) variables.concat(pipeline.predefined_variables) variables.concat(runner.predefined_variables) if runner - variables.concat(project.deployment_variables(environment: environment)) if has_environment? + variables.concat(project.deployment_variables(environment: environment)) if environment variables.concat(yaml_variables) variables.concat(user_variables) - variables.concat(project.group.secret_variables_for(ref, project)) if project.group - variables.concat(secret_variables(environment: environment)) + variables.concat(secret_group_variables) + variables.concat(secret_project_variables(environment: environment)) variables.concat(trigger_request.user_variables) if trigger_request variables.concat(pipeline.variables) variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule - variables.concat(persisted_environment_variables) if environment end + end + + ## + # Variables that do not depend on the environment name. + # + def simple_variables + strong_memoize(:simple_variables) do + scoped_variables(environment: nil).to_runner_variables + end + end - collection.to_runner_variables + ## + # All variables, including persisted environment variables. + # + def variables + Gitlab::Ci::Variables::Collection.new + .concat(persisted_variables) + .concat(scoped_variables) + .concat(persisted_environment_variables) + .to_runner_variables + end + + ## + # Regular Ruby hash of scoped variables, without duplicates that are + # possible to be present in an array of hashes returned from `variables`. + # + def scoped_variables_hash + scoped_variables.to_hash end def features @@ -451,9 +487,14 @@ module Ci end end - def secret_variables(environment: persisted_environment) + def secret_group_variables + return [] unless project.group + + project.group.secret_variables_for(ref, project) + end + + def secret_project_variables(environment: persisted_environment) project.secret_variables_for(ref: ref, environment: environment) - .map(&:to_runner_variable) end def steps @@ -550,6 +591,21 @@ module Ci CI_REGISTRY_USER = 'gitlab-ci-token'.freeze + def persisted_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + return variables unless persisted? + + variables + .append(key: 'CI_JOB_ID', value: id.to_s) + .append(key: 'CI_JOB_TOKEN', value: token, public: false) + .append(key: 'CI_BUILD_ID', value: id.to_s) + .append(key: 'CI_BUILD_TOKEN', value: token, public: false) + .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) + .append(key: 'CI_REGISTRY_PASSWORD', value: token, public: false) + .append(key: 'CI_REPOSITORY_URL', value: repo_url, public: false) + end + end + def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI', value: 'true') @@ -558,16 +614,11 @@ module Ci variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) variables.append(key: 'CI_SERVER_REVISION', value: Gitlab::REVISION) - variables.append(key: 'CI_JOB_ID', value: id.to_s) variables.append(key: 'CI_JOB_NAME', value: name) variables.append(key: 'CI_JOB_STAGE', value: stage) - variables.append(key: 'CI_JOB_TOKEN', value: token, public: false) variables.append(key: 'CI_COMMIT_SHA', value: sha) variables.append(key: 'CI_COMMIT_REF_NAME', value: ref) variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug) - variables.append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) - variables.append(key: 'CI_REGISTRY_PASSWORD', value: token, public: false) - variables.append(key: 'CI_REPOSITORY_URL', value: repo_url, public: false) variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request variables.append(key: "CI_JOB_MANUAL", value: 'true') if action? @@ -575,23 +626,8 @@ module Ci end end - def persisted_environment_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - return variables unless persisted_environment - - variables.concat(persisted_environment.predefined_variables) - - # Here we're passing unexpanded environment_url for runner to expand, - # and we need to make sure that CI_ENVIRONMENT_NAME and - # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. - variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url - end - end - def legacy_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI_BUILD_ID', value: id.to_s) - variables.append(key: 'CI_BUILD_TOKEN', value: token, public: false) variables.append(key: 'CI_BUILD_REF', value: sha) variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) variables.append(key: 'CI_BUILD_REF_NAME', value: ref) @@ -604,6 +640,19 @@ module Ci end end + def persisted_environment_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + return variables unless persisted? && persisted_environment.present? + + variables.concat(persisted_environment.predefined_variables) + + # Here we're passing unexpanded environment_url for runner to expand, + # and we need to make sure that CI_ENVIRONMENT_NAME and + # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. + variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url + end + end + def environment_url options&.dig(:environment, :url) || persisted_environment&.external_url end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb new file mode 100644 index 00000000000..96762f8845c --- /dev/null +++ b/app/models/ci/build_metadata.rb @@ -0,0 +1,35 @@ +module Ci + # The purpose of this class is to store Build related data that can be disposed. + # Data that should be persisted forever, should be stored with Ci::Build model. + class BuildMetadata < ActiveRecord::Base + extend Gitlab::Ci::Model + include Presentable + include ChronicDurationAttribute + + self.table_name = 'ci_builds_metadata' + + belongs_to :build, class_name: 'Ci::Build' + belongs_to :project + + validates :build, presence: true + validates :project, presence: true + + chronic_duration_attr_reader :timeout_human_readable, :timeout + + enum timeout_source: { + unknown_timeout_source: 1, + project_timeout_source: 2, + runner_timeout_source: 3 + } + + def update_timeout_state + return unless build.runner.present? + + project_timeout = project&.build_timeout + timeout = [project_timeout, build.runner.maximum_timeout].compact.min + timeout_source = timeout < project_timeout ? :runner_timeout_source : :project_timeout_source + + update(timeout: timeout, timeout_source: timeout_source) + end + end +end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 7173f88f1c7..5a4c56ec0dc 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -3,12 +3,13 @@ module Ci extend Gitlab::Ci::Model include Gitlab::SQL::Pattern include RedisCacheable + include ChronicDurationAttribute RUNNER_QUEUE_EXPIRY_TIME = 60.minutes ONLINE_CONTACT_TIMEOUT = 1.hour UPDATE_DB_RUNNER_INFO_EVERY = 40.minutes AVAILABLE_SCOPES = %w[specific shared active paused online].freeze - FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level].freeze + FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze has_many :builds has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -51,6 +52,12 @@ module Ci cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address + chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout + + validates :maximum_timeout, allow_nil: true, + numericality: { greater_than_or_equal_to: 600, + message: 'needs to be at least 10 minutes' } + # Searches for runners matching the given query. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index bfdfc5ae6fe..77947d515c1 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -51,6 +51,10 @@ module Clusters scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } + scope :user_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:user]) } + scope :gcp_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:gcp]) } + scope :gcp_installed, -> { gcp_provided.includes(:provider_gcp).where(cluster_providers_gcp: { status: ::Clusters::Providers::Gcp.state_machines[:status].states[:created].value }) } + scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } def status_name diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 7b7c8eac773..8f3eb75bfa9 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -4,6 +4,8 @@ module Clusters extend ActiveSupport::Concern included do + scope :installed, -> { where(status: self.state_machines[:status].states[:installed].value) } + state_machine :status, initial: :not_installable do state :not_installable, value: -2 state :errored, value: -1 diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 9fb5b7efec6..3469d5d795c 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -141,7 +141,7 @@ class CommitStatus < ActiveRecord::Base end def group_name - name.to_s.gsub(%r{\d+[\.\s:/\\]+\d+\s*}, '').strip + name.to_s.gsub(%r{\d+[\s:/\\]+\d+\s*}, '').strip end def failed_but_allowed? diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb new file mode 100644 index 00000000000..fa1eafb1d7a --- /dev/null +++ b/app/models/concerns/chronic_duration_attribute.rb @@ -0,0 +1,39 @@ +module ChronicDurationAttribute + extend ActiveSupport::Concern + + class_methods do + def chronic_duration_attr_reader(virtual_attribute, source_attribute) + define_method(virtual_attribute) do + chronic_duration_attributes[virtual_attribute] || output_chronic_duration_attribute(source_attribute) + end + end + + def chronic_duration_attr_writer(virtual_attribute, source_attribute) + chronic_duration_attr_reader(virtual_attribute, source_attribute) + + define_method("#{virtual_attribute}=") do |value| + chronic_duration_attributes[virtual_attribute] = value.presence || '' + + begin + new_value = ChronicDuration.parse(value).to_i if value.present? + assign_attributes(source_attribute => new_value) + rescue ChronicDuration::DurationParseError + # ignore error as it will be caught by validation + end + end + + validates virtual_attribute, allow_nil: true, duration: true + end + + alias_method :chronic_duration_attr, :chronic_duration_attr_writer + end + + def chronic_duration_attributes + @chronic_duration_attributes ||= {} + end + + def output_chronic_duration_attribute(source_attribute) + value = attributes[source_attribute.to_s] + ChronicDuration.output(value, format: :short) if value + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 5a566f3ac02..b45395343cc 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -137,7 +137,7 @@ module Issuable fuzzy_search(query, [:title, :description]) end - def sort(method, excluded_labels: []) + def sort_by_attribute(method, excluded_labels: []) sorted = case method.to_s when 'downvotes_desc' then order_downvotes_desc diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index caf8afa97f9..5130ecec472 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -45,11 +45,11 @@ module Milestoneish end def sorted_issues(user) - issues_visible_to_user(user).preload_associations.sort('label_priority') + issues_visible_to_user(user).preload_associations.sort_by_attribute('label_priority') end def sorted_merge_requests - merge_requests.sort('label_priority') + merge_requests.sort_by_attribute('label_priority') end def upcoming? diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index c2e0a5fa126..89a74b7dcb1 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -27,6 +27,10 @@ class DeployKey < Key self.private? end + def user + super || User.ghost + end + def has_access_to?(project) deploy_keys_project_for(project).present? end diff --git a/app/models/group.rb b/app/models/group.rb index d99af79b5fe..3cfe21ac93b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -53,7 +53,7 @@ class Group < Namespace Gitlab::Database.postgresql? end - def sort(method) + def sort_by_attribute(method) if method == 'storage_size_desc' # storage_size is a virtual column so we need to # pass a string to avoid AR adding the table name diff --git a/app/models/issue.rb b/app/models/issue.rb index 7bfc45c1f43..13abc6c1a0d 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -23,6 +23,7 @@ class Issue < ActiveRecord::Base belongs_to :project belongs_to :moved_to, class_name: 'Issue' + belongs_to :closed_by, class_name: 'User' has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) } @@ -78,6 +79,11 @@ class Issue < ActiveRecord::Base before_transition any => :closed do |issue| issue.closed_at = Time.zone.now end + + before_transition closed: :opened do |issue| + issue.closed_at = nil + issue.closed_by = nil + end end class << self @@ -110,7 +116,7 @@ class Issue < ActiveRecord::Base 'project_id' end - def self.sort(method, excluded_labels: []) + def self.sort_by_attribute(method, excluded_labels: []) case method.to_s when 'due_date' then order_due_date_asc when 'due_date_asc' then order_due_date_asc diff --git a/app/models/member.rb b/app/models/member.rb index e1a32148538..eac4a22a03f 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -96,7 +96,7 @@ class Member < ActiveRecord::Base joins(:user).merge(User.search(query)) end - def sort(method) + def sort_by_attribute(method) case method.to_s when 'access_level_asc' then reorder(access_level: :asc) when 'access_level_desc' then reorder(access_level: :desc) diff --git a/app/models/milestone.rb b/app/models/milestone.rb index e7d397f40f5..dafae58d121 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -138,7 +138,7 @@ class Milestone < ActiveRecord::Base User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).uniq end - def self.sort(method) + def self.sort_by_attribute(method) case method.to_s when 'due_date_asc' reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC')) diff --git a/app/models/note.rb b/app/models/note.rb index 787a80f0196..0f5fb529a87 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -379,12 +379,15 @@ class Note < ActiveRecord::Base def expire_etag_cache return unless noteable&.discussions_rendered_on_frontend? - key = Gitlab::Routing.url_helpers.project_noteable_notes_path( + Gitlab::EtagCaching::Store.new.touch(etag_key) + end + + def etag_key + Gitlab::Routing.url_helpers.project_noteable_notes_path( project, target_type: noteable_type.underscore, target_id: noteable_id ) - Gitlab::EtagCaching::Store.new.touch(key) end def touch(*args) diff --git a/app/models/project.rb b/app/models/project.rb index 6a420663644..714a15ade9c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -436,7 +436,7 @@ class Project < ActiveRecord::Base Gitlab::VisibilityLevel.options end - def sort(method) + def sort_by_attribute(method) case method.to_s when 'storage_size_desc' # storage_size is a joined column so we need to @@ -566,9 +566,7 @@ class Project < ActiveRecord::Base def add_import_job job_id = if forked? - RepositoryForkWorker.perform_async(id, - forked_from_project.repository_storage_path, - forked_from_project.disk_path) + RepositoryForkWorker.perform_async(id) elsif gitlab_project_import? # Do not retry on Import/Export until https://gitlab.com/gitlab-org/gitlab-ce/issues/26189 is solved. RepositoryImportWorker.set(retry: false).perform_async(self.id) @@ -1346,20 +1344,19 @@ class Project < ActiveRecord::Base Dir.exist?(public_pages_path) end - def pages_url - subdomain, _, url_path = full_path.partition('/') - - # The hostname always needs to be in downcased - # All web servers convert hostname to lowercase - host = "#{subdomain}.#{Settings.pages.host}".downcase - + def pages_group_url # The host in URL always needs to be downcased - url = Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix| - "#{prefix}#{subdomain}." + Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix| + "#{prefix}#{pages_subdomain}." end.downcase + end + + def pages_url + url = pages_group_url + url_path = full_path.partition('/').last # If the project path is the same as host, we serve it as group page - return url if host == url_path + return url if url == "#{Settings.pages.protocol}://#{url_path}" "#{url}/#{url_path}" end @@ -1545,8 +1542,8 @@ class Project < ActiveRecord::Base @errors = original_errors end - def add_export_job(current_user:, params: {}) - job_id = ProjectExportWorker.perform_async(current_user.id, self.id, params) + def add_export_job(current_user:, after_export_strategy: nil, params: {}) + job_id = ProjectExportWorker.perform_async(current_user.id, self.id, after_export_strategy, params) if job_id Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}" @@ -1572,6 +1569,8 @@ class Project < ActiveRecord::Base def export_status if export_in_progress? :started + elsif after_export_in_progress? + :after_export_action elsif export_project_path :finished else @@ -1583,12 +1582,22 @@ class Project < ActiveRecord::Base import_export_shared.active_export_count > 0 end + def after_export_in_progress? + import_export_shared.after_export_in_progress? + end + def remove_exports return nil unless export_path.present? FileUtils.rm_rf(export_path) end + def remove_exported_project_file + return unless export_project_path.present? + + FileUtils.rm_f(export_project_path) + end + def full_path_slug Gitlab::Utils.slugify(full_path.to_s) end diff --git a/app/models/repository.rb b/app/models/repository.rb index 2ba1c6cb8c9..fd1afafe4df 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -249,13 +249,13 @@ class Repository end def diverging_commit_counts(branch) - root_ref_hash = raw_repository.commit(root_ref).id + @root_ref_hash ||= raw_repository.commit(root_ref).id cache.fetch(:"diverging_commit_counts_#{branch.name}") do # Rugged seems to throw a `ReferenceError` when given branch_names rather # than SHA-1 hashes number_commits_behind, number_commits_ahead = raw_repository.count_commits_between( - root_ref_hash, + @root_ref_hash, branch.dereferenced_target.sha, left_right: true, max_count: MAX_DIVERGING_COUNT) diff --git a/app/models/service.rb b/app/models/service.rb index 1dcb79157a2..7424cef0fc0 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -273,6 +273,7 @@ class Service < ActiveRecord::Base def self.build_from_template(project_id, template) service = template.dup + service.active = false unless service.valid? service.template = false service.project_id = project_id service diff --git a/app/models/todo.rb b/app/models/todo.rb index 8afacd188e0..a2ab405fdbe 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -50,7 +50,7 @@ class Todo < ActiveRecord::Base # Priority sorting isn't displayed in the dropdown, because we don't show # milestones, but still show something if the user has a URL with that # selected. - def sort(method) + def sort_by_attribute(method) sorted = case method.to_s when 'priority', 'label_priority' then order_by_labels_priority diff --git a/app/models/user.rb b/app/models/user.rb index 187878f4fb5..ba51595e6a3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -82,11 +82,8 @@ class User < ActiveRecord::Base has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, inverse_of: :owner, autosave: true # rubocop:disable Cop/ActiveRecordDependent # Profile - has_many :keys, -> do - type = Key.arel_table[:type] - where(type.not_eq('DeployKey').or(type.eq(nil))) - end, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :keys, -> { where(type: ['Key', nil]) }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent has_many :gpg_keys has_many :emails, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -259,7 +256,7 @@ class User < ActiveRecord::Base end end - def sort(method) + def sort_by_attribute(method) order_method = method || 'id_desc' case order_method.to_s diff --git a/app/presenters/ci/build_metadata_presenter.rb b/app/presenters/ci/build_metadata_presenter.rb new file mode 100644 index 00000000000..5048f967ea8 --- /dev/null +++ b/app/presenters/ci/build_metadata_presenter.rb @@ -0,0 +1,18 @@ +module Ci + class BuildMetadataPresenter < Gitlab::View::Presenter::Delegated + TIMEOUT_SOURCES = { + unknown_timeout_source: nil, + project_timeout_source: 'project', + runner_timeout_source: 'runner' + }.freeze + + presents :metadata + + def timeout_source + return unless metadata.timeout_source? + + TIMEOUT_SOURCES[metadata.timeout_source.to_sym] || + metadata.timeout_source + end + end +end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 69d46f5ec14..ca4480fe2b1 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -5,6 +5,8 @@ class BuildDetailsEntity < JobEntity expose :runner, using: RunnerEntity expose :pipeline, using: PipelineEntity + expose :metadata, using: BuildMetadataEntity + expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build| erase_project_job_path(project, build) diff --git a/app/serializers/build_metadata_entity.rb b/app/serializers/build_metadata_entity.rb new file mode 100644 index 00000000000..39f429aa6c3 --- /dev/null +++ b/app/serializers/build_metadata_entity.rb @@ -0,0 +1,9 @@ +class BuildMetadataEntity < Grape::Entity + expose :timeout_human_readable do |metadata| + metadata.timeout_human_readable unless metadata.timeout.nil? + end + + expose :timeout_source do |metadata| + metadata.present.timeout_source + end +end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index bbbcf6a97c1..718fb35e62d 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -4,7 +4,9 @@ class DiscussionEntity < Grape::Entity expose :id, :reply_id expose :expanded?, as: :expanded - expose :notes, using: NoteEntity + expose :notes do |discussion, opts| + request.note_entity.represent(discussion.notes, opts) + end expose :individual_note?, as: :individual_note expose :resolvable?, as: :resolvable @@ -12,7 +14,7 @@ class DiscussionEntity < Grape::Entity expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion| resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id) end - expose :resolve_with_issue_path do |discussion| + expose :resolve_with_issue_path, if: -> (d, _) { d.resolvable? } do |discussion| new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) end diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 4ccf0bca476..c964aa9c99b 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -5,10 +5,6 @@ class NoteEntity < API::Entities::Note expose :author, using: NoteUserEntity - expose :human_access do |note| - note.project.team.human_max_access(note.author_id) - end - unexpose :note, as: :body expose :note @@ -37,36 +33,10 @@ class NoteEntity < API::Entities::Note expose :emoji_awardable?, as: :emoji_awardable expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity - expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note| - if note.for_personal_snippet? - toggle_award_emoji_snippet_note_path(note.noteable, note) - else - toggle_award_emoji_project_note_path(note.project, note.id) - end - end expose :report_abuse_path do |note| new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note)) end - expose :path do |note| - if note.for_personal_snippet? - snippet_note_path(note.noteable, note) - else - project_note_path(note.project, note) - end - end - - expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| - resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id) - end - - expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| - new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id) - end - expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } - expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| - delete_attachment_project_note_path(note.project, note) - end end diff --git a/app/serializers/note_serializer.rb b/app/serializers/note_serializer.rb deleted file mode 100644 index 2afe40d7a34..00000000000 --- a/app/serializers/note_serializer.rb +++ /dev/null @@ -1,3 +0,0 @@ -class NoteSerializer < BaseSerializer - entity NoteEntity -end diff --git a/app/serializers/project_note_entity.rb b/app/serializers/project_note_entity.rb new file mode 100644 index 00000000000..e541bfbee8d --- /dev/null +++ b/app/serializers/project_note_entity.rb @@ -0,0 +1,25 @@ +class ProjectNoteEntity < NoteEntity + expose :human_access do |note| + note.project.team.human_max_access(note.author_id) + end + + expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note| + toggle_award_emoji_project_note_path(note.project, note.id) + end + + expose :path do |note| + project_note_path(note.project, note) + end + + expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id) + end + + expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id) + end + + expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| + delete_attachment_project_note_path(note.project, note) + end +end diff --git a/app/serializers/project_note_serializer.rb b/app/serializers/project_note_serializer.rb new file mode 100644 index 00000000000..763ad0bdb3f --- /dev/null +++ b/app/serializers/project_note_serializer.rb @@ -0,0 +1,3 @@ +class ProjectNoteSerializer < BaseSerializer + entity ProjectNoteEntity +end diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb index 3e40ecf1c1c..a7c2e21e92b 100644 --- a/app/serializers/status_entity.rb +++ b/app/serializers/status_entity.rb @@ -7,8 +7,14 @@ class StatusEntity < Grape::Entity expose :details_path expose :favicon do |status| - dir = 'ci_favicons' - dir = File.join(dir, 'dev') if Rails.env.development? + dir = + if Gitlab::Utils.to_boolean(ENV['CANARY']) + File.join('ci_favicons', 'canary') + elsif Rails.env.development? + File.join('ci_favicons', 'dev') + else + 'ci_favicons' + end ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico")) end diff --git a/app/services/boards/list_service.rb b/app/services/boards/list_service.rb index 6d0dd0a9f99..9269b8d2620 100644 --- a/app/services/boards/list_service.rb +++ b/app/services/boards/list_service.rb @@ -2,11 +2,15 @@ module Boards class ListService < Boards::BaseService def execute create_board! if parent.boards.empty? - parent.boards + boards end private + def boards + parent.boards + end + def create_board! Boards::CreateService.new(parent, current_user).execute end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 0c5cf2c62ad..fee5bc38f7b 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -23,6 +23,7 @@ module Issues end if project.issues_enabled? && issue.close + issue.update(closed_by: current_user) event_service.close_issue(issue, current_user) create_note(issue, commit) if system_note notification_service.close_issue(issue, current_user) if notifications diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 7fa1387084c..633e2c8236c 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -90,9 +90,6 @@ module Projects unless @project.gitlab_project_import? @project.write_repository_config @project.create_wiki unless skip_wiki? - create_services_from_active_templates(@project) - - @project.create_labels end event_service.create_project(@project, current_user) @@ -121,21 +118,29 @@ module Projects Project.transaction do @project.create_or_update_import_data(data: import_data[:data], credentials: import_data[:credentials]) if import_data - if @project.save && !@project.import? - raise 'Failed to create repository' unless @project.create_repository + if @project.save + unless @project.gitlab_project_import? + create_services_from_active_templates(@project) + @project.create_labels + end + + unless @project.import? + raise 'Failed to create repository' unless @project.create_repository + end end end end def fail(error:) message = "Unable to save project. Error: #{error}" - message << "Project ID: #{@project.id}" if @project && @project.id + log_message = message.dup - Rails.logger.error(message) + log_message << " Project ID: #{@project.id}" if @project&.id + Rails.logger.error(log_message) - if @project && @project.import? + if @project @project.errors.add(:base, message) - @project.mark_import_as_failed(message) + @project.mark_import_as_failed(message) if @project.import? end @project diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index d16aa3de639..402cddd3ec1 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -1,22 +1,36 @@ module Projects module ImportExport class ExportService < BaseService - def execute(_options = {}) + def execute(after_export_strategy = nil, options = {}) @shared = project.import_export_shared - save_all + + save_all! + execute_after_export_action(after_export_strategy) end private - def save_all - if [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver].all?(&:save) + def execute_after_export_action(after_export_strategy) + return unless after_export_strategy + + unless after_export_strategy.execute(current_user, project) + cleanup_and_notify_error + end + end + + def save_all! + if save_services Gitlab::ImportExport::Saver.save(project: project, shared: @shared) notify_success else - cleanup_and_notify + cleanup_and_notify_error! end end + def save_services + [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver].all?(&:save) + end + def version_saver Gitlab::ImportExport::VersionSaver.new(shared: @shared) end @@ -41,19 +55,22 @@ module Projects Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: @shared) end - def cleanup_and_notify + def cleanup_and_notify_error Rails.logger.error("Import/Export - Project #{project.name} with ID: #{project.id} export error - #{@shared.errors.join(', ')}") FileUtils.rm_rf(@shared.export_path) notify_error + end + + def cleanup_and_notify_error! + cleanup_and_notify_error + raise Gitlab::ImportExport::Error.new(@shared.errors.join(', ')) end def notify_success Rails.logger.info("Import/Export - Project #{project.name} with ID: #{project.id} successfully exported") - - notification_service.project_exported(@project, @current_user) end def notify_error diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index a34024f4f80..bdd9598f85a 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -28,7 +28,11 @@ module Projects def add_repository_to_project if project.external_import? && !unknown_url? - raise Error, 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url, valid_ports: Project::VALID_IMPORT_PORTS) + begin + Gitlab::UrlBlocker.validate!(project.import_url, valid_ports: Project::VALID_IMPORT_PORTS) + rescue Gitlab::UrlBlocker::BlockedUrlError => e + raise Error, "Blocked import URL: #{e.message}" + end end # We should skip the repository for a GitHub import or GitLab project import, @@ -57,7 +61,7 @@ module Projects project.ensure_repository project.repository.fetch_as_mirror(project.import_url, refmap: refmap) else - gitlab_shell.import_repository(project.repository_storage_path, project.disk_path, project.import_url) + gitlab_shell.import_repository(project.repository_storage, project.disk_path, project.import_url) end rescue Gitlab::Shell::Error, Gitlab::Git::RepositoryMirroring::RemoteError => e # Expire cache to prevent scenarios such as: diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 5bf8208e035..7e228d1833d 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -31,15 +31,17 @@ module Projects # Check if we did extract public directory archive_public_path = File.join(archive_path, 'public') - raise FailedToExtractError, 'pages miss the public folder' unless Dir.exist?(archive_public_path) + raise InvaildStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path) raise InvaildStateError, 'pages are outdated' unless latest? deploy_page!(archive_public_path) success end - rescue InvaildStateError, FailedToExtractError => e - register_failure + rescue InvaildStateError => e error(e.message) + rescue => e + error(e.message, false) + raise e end private @@ -50,12 +52,13 @@ module Projects super end - def error(message, http_status = nil) + def error(message, allow_delete_artifact = true) + register_failure log_error("Projects::UpdatePagesService: #{message}") @status.allow_failure = !latest? @status.description = message @status.drop(:script_failure) - delete_artifact! + delete_artifact! if allow_delete_artifact super end @@ -76,7 +79,7 @@ module Projects elsif artifacts.ends_with?('.zip') extract_zip_archive!(temp_path) else - raise FailedToExtractError, 'unsupported artifacts format' + raise InvaildStateError, 'unsupported artifacts format' end end @@ -91,13 +94,13 @@ module Projects end def extract_zip_archive!(temp_path) - raise FailedToExtractError, 'missing artifacts metadata' unless build.artifacts_metadata? + raise InvaildStateError, 'missing artifacts metadata' unless build.artifacts_metadata? # Calculate page size after extract public_entry = build.artifacts_metadata_entry(SITE_PATH, recursive: true) if public_entry.total_size > max_size - raise FailedToExtractError, "artifacts for pages are too large: #{public_entry.total_size}" + raise InvaildStateError, "artifacts for pages are too large: #{public_entry.total_size}" end # Requires UnZip at least 6.00 Info-ZIP. @@ -178,6 +181,9 @@ module Projects def latest_sha project.commit(build.ref).try(:sha).to_s + ensure + # Close any file descriptors that were opened and free libgit2 buffers + project.cleanup end def sha diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index 30cc4425ae4..4028b052768 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -228,16 +228,9 @@ module ObjectStorage raise 'Failed to update object store' unless updated end - def use_file - if file_storage? - return yield path - end - - begin - cache_stored_file! - yield cache_path - ensure - cache_storage.delete_dir!(cache_path(nil)) + def use_file(&blk) + with_exclusive_lease do + unsafe_use_file(&blk) end end @@ -247,12 +240,9 @@ module ObjectStorage # new_store: Enum (Store::LOCAL, Store::REMOTE) # def migrate!(new_store) - uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain - raise 'Already running' unless uuid - - unsafe_migrate!(new_store) - ensure - Gitlab::ExclusiveLease.cancel(exclusive_lease_key, uuid) + with_exclusive_lease do + unsafe_migrate!(new_store) + end end def schedule_background_upload(*args) @@ -384,6 +374,15 @@ module ObjectStorage "object_storage_migrate:#{model.class}:#{model.id}" end + def with_exclusive_lease + uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain + raise 'exclusive lease already taken' unless uuid + + yield uuid + ensure + Gitlab::ExclusiveLease.cancel(exclusive_lease_key, uuid) + end + # # Move the file to another store # @@ -418,4 +417,18 @@ module ObjectStorage raise e end end + + def unsafe_use_file + if file_storage? + return yield path + end + + begin + cache_stored_file! + yield cache_path + ensure + FileUtils.rm_f(cache_path) + cache_storage.delete_dir!(cache_path(nil)) + end + end end diff --git a/app/validators/certificate_fingerprint_validator.rb b/app/validators/certificate_fingerprint_validator.rb new file mode 100644 index 00000000000..17df756183a --- /dev/null +++ b/app/validators/certificate_fingerprint_validator.rb @@ -0,0 +1,9 @@ +class CertificateFingerprintValidator < ActiveModel::EachValidator + FINGERPRINT_PATTERN = /\A([a-zA-Z0-9]{2}[\s\-:]?){16,}\z/.freeze + + def validate_each(record, attribute, value) + unless value.try(:match, FINGERPRINT_PATTERN) + record.errors.add(attribute, "must be a hash containing only letters, numbers, spaces, : and -") + end + end +end diff --git a/app/validators/importable_url_validator.rb b/app/validators/importable_url_validator.rb index 3ec1594e202..612d3c71913 100644 --- a/app/validators/importable_url_validator.rb +++ b/app/validators/importable_url_validator.rb @@ -4,8 +4,8 @@ # protect against Server-side Request Forgery (SSRF). class ImportableUrlValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - if Gitlab::UrlBlocker.blocked_url?(value, valid_ports: Project::VALID_IMPORT_PORTS) - record.errors.add(attribute, "imports are not allowed from that URL") - end + Gitlab::UrlBlocker.validate!(value, valid_ports: Project::VALID_IMPORT_PORTS) + rescue Gitlab::UrlBlocker::BlockedUrlError => e + record.errors.add(attribute, "is blocked: #{e.message}") end end diff --git a/app/validators/top_level_group_validator.rb b/app/validators/top_level_group_validator.rb new file mode 100644 index 00000000000..7e2e735e0cf --- /dev/null +++ b/app/validators/top_level_group_validator.rb @@ -0,0 +1,7 @@ +class TopLevelGroupValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + if value&.subgroup? + record.errors.add(attribute, "must be a top level Group") + end + end +end diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml new file mode 100644 index 00000000000..bb3fa26a33e --- /dev/null +++ b/app/views/admin/application_settings/_abuse.html.haml @@ -0,0 +1,12 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :admin_notification_email, 'Abuse reports notification email', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :admin_notification_email, class: 'form-control' + .help-block + Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_background_jobs.html.haml b/app/views/admin/application_settings/_background_jobs.html.haml new file mode 100644 index 00000000000..8198a822a10 --- /dev/null +++ b/app/views/admin/application_settings/_background_jobs.html.haml @@ -0,0 +1,30 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + %p + These settings require a + = link_to 'restart', help_page_path('administration/restart_gitlab') + to take effect. + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :sidekiq_throttling_enabled do + = f.check_box :sidekiq_throttling_enabled + Enable Sidekiq Job Throttling + .help-block + Limit the amount of resources slow running jobs are assigned. + .form-group + = f.label :sidekiq_throttling_queues, 'Sidekiq queues to throttle', class: 'control-label col-sm-2' + .col-sm-10 + = f.select :sidekiq_throttling_queues, sidekiq_queue_options_for_select, { include_hidden: false }, multiple: true, class: 'select2 select-wide', data: { field: 'sidekiq_throttling_queues' } + .help-block + Choose which queues you wish to throttle. + .form-group + = f.label :sidekiq_throttling_factor, 'Throttling Factor', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :sidekiq_throttling_factor, class: 'form-control', min: '0.01', max: '0.99', step: '0.01' + .help-block + The factor by which the queues should be throttled. A value between 0.0 and 1.0, exclusive. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 636535fba84..9ab2c2892b2 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -9,226 +9,6 @@ .col-sm-10 = f.number_field :container_registry_token_expire_delay, class: 'form-control' - %fieldset - %legend Profiling - Performance Bar - %p - Enable the Performance Bar for a given group. - = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/performance_bar') - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :performance_bar_enabled do - = f.check_box :performance_bar_enabled - Enable the Performance Bar - .form-group - = f.label :performance_bar_allowed_group_id, 'Allowed group', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :performance_bar_allowed_group_id, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path - - %fieldset - %legend Background Jobs - %p - These settings require a - = link_to 'restart', help_page_path('administration/restart_gitlab') - to take effect. - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :sidekiq_throttling_enabled do - = f.check_box :sidekiq_throttling_enabled - Enable Sidekiq Job Throttling - .help-block - Limit the amount of resources slow running jobs are assigned. - .form-group - = f.label :sidekiq_throttling_queues, 'Sidekiq queues to throttle', class: 'control-label col-sm-2' - .col-sm-10 - = f.select :sidekiq_throttling_queues, sidekiq_queue_options_for_select, { include_hidden: false }, multiple: true, class: 'select2 select-wide', data: { field: 'sidekiq_throttling_queues' } - .help-block - Choose which queues you wish to throttle. - .form-group - = f.label :sidekiq_throttling_factor, 'Throttling Factor', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :sidekiq_throttling_factor, class: 'form-control', min: '0.01', max: '0.99', step: '0.01' - .help-block - The factor by which the queues should be throttled. A value between 0.0 and 1.0, exclusive. - - %fieldset - %legend Spam and Anti-bot Protection - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :recaptcha_enabled do - = f.check_box :recaptcha_enabled - Enable reCAPTCHA - %span.help-block#recaptcha_help_block Helps prevent bots from creating accounts - - .form-group - = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :recaptcha_site_key, class: 'form-control' - .help-block - Generate site and private keys at - %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha - - .form-group - = f.label :recaptcha_private_key, 'reCAPTCHA Private Key', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :recaptcha_private_key, class: 'form-control' - - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :akismet_enabled do - = f.check_box :akismet_enabled - Enable Akismet - %span.help-block#akismet_help_block Helps prevent bots from creating issues - - .form-group - = f.label :akismet_api_key, 'Akismet API Key', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :akismet_api_key, class: 'form-control' - .help-block - Generate API key at - %a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com - - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :unique_ips_limit_enabled do - = f.check_box :unique_ips_limit_enabled - Limit sign in from multiple ips - %span.help-block#unique_ip_help_block - Helps prevent malicious users hide their activity - - .form-group - = f.label :unique_ips_limit_per_user, 'IPs per user', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :unique_ips_limit_per_user, class: 'form-control' - .help-block - Maximum number of unique IPs per user - - .form-group - = f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :unique_ips_limit_time_window, class: 'form-control' - .help-block - How many seconds an IP will be counted towards the limit - - %fieldset - %legend Abuse reports - .form-group - = f.label :admin_notification_email, 'Abuse reports notification email', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :admin_notification_email, class: 'form-control' - .help-block - Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. - - %fieldset - %legend Error Reporting and Logging - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :sentry_enabled do - = f.check_box :sentry_enabled - Enable Sentry - .help-block - %p This setting requires a restart to take effect. - Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: - %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com - - .form-group - = f.label :sentry_dsn, 'Sentry DSN', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :sentry_dsn, class: 'form-control' - - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :clientside_sentry_enabled do - = f.check_box :clientside_sentry_enabled - Enable Clientside Sentry - .help-block - Sentry can also be used for reporting and logging clientside exceptions. - %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ - - .form-group - = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :clientside_sentry_dsn, class: 'form-control' - - %fieldset - %legend Repository Storage - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :hashed_storage_enabled do - = f.check_box :hashed_storage_enabled - Create new projects using hashed storage paths - .help-block - Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents - repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance. - %em (EXPERIMENTAL) - .form-group - = f.label :repository_storages, 'Storage paths for new projects', class: 'control-label col-sm-2' - .col-sm-10 - = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages), - {include_hidden: false}, multiple: true, class: 'form-control' - .help-block - Manage repository storage paths. Learn more in the - = succeed "." do - = link_to "repository storages documentation", help_page_path("administration/repository_storages") - - %fieldset - %legend Git Storage Circuitbreaker settings - .form-group - = f.label :circuitbreaker_check_interval, _('Check interval'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_check_interval, class: 'form-control' - .help-block - = circuitbreaker_check_interval_help_text - .form-group - = f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_access_retries, class: 'form-control' - .help-block - = circuitbreaker_access_retries_help_text - .form-group - = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_storage_timeout, class: 'form-control' - .help-block - = circuitbreaker_storage_timeout_help_text - .form-group - = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control' - .help-block - = circuitbreaker_failure_count_help_text - .form-group - = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control' - .help-block - = circuitbreaker_failure_reset_time_help_text - - %fieldset - %legend Repository Checks - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :repository_checks_enabled do - = f.check_box :repository_checks_enabled - Enable Repository Checks - .help-block - GitLab will periodically run - %a{ href: 'https://www.kernel.org/pub/software/scm/git/docs/git-fsck.html', target: 'blank' } 'git fsck' - in all project and wiki repositories to look for silent disk corruption issues. - .form-group - .col-sm-offset-2.col-sm-10 - = link_to 'Clear all repository checks', clear_repository_check_states_admin_application_settings_path, data: { confirm: 'This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?' }, method: :put, class: "btn btn-sm btn-remove" - .help-block - If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. - - if koding_enabled? %fieldset %legend Koding @@ -323,44 +103,6 @@ By default GitLab sends emails in HTML and plain text formats so mail clients can choose what format to use. Disable this option if you only want to send emails in plain text format. - %fieldset - %legend Automatic Git repository housekeeping - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :housekeeping_enabled do - = f.check_box :housekeeping_enabled - Enable automatic repository housekeeping (git repack, git gc) - .help-block - If you keep automatic housekeeping disabled for a long time Git - repository access on your GitLab server will become slower and your - repositories will use more disk space. We recommend to always leave - this enabled. - .checkbox - = f.label :housekeeping_bitmaps_enabled do - = f.check_box :housekeeping_bitmaps_enabled - Enable Git pack file bitmap creation - .help-block - Creating pack file bitmaps makes housekeeping take a little longer but - bitmaps should accelerate 'git clone' performance. - .form-group - = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :housekeeping_incremental_repack_period, class: 'form-control' - .help-block - Number of Git pushes after which an incremental 'git repack' is run. - .form-group - = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :housekeeping_full_repack_period, class: 'form-control' - .help-block - Number of Git pushes after which a full 'git repack' is run. - .form-group - = f.label :housekeeping_gc_period, 'Git GC period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :housekeeping_gc_period, class: 'form-control' - .help-block - Number of Git pushes after which 'git gc' is run. %fieldset %legend Gitaly Timeouts @@ -427,65 +169,5 @@ AuthorizedKeysCommand. Click on the help icon for more details. = link_to icon('question-circle'), help_page_path('administration/operations/fast_ssh_key_lookup') - %fieldset - %legend User and IP Rate Limits - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :throttle_unauthenticated_enabled do - = f.check_box :throttle_unauthenticated_enabled - Enable unauthenticated request rate limit - %span.help-block - Helps reduce request volume (e.g. from crawlers or abusive bots) - .form-group - = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control' - .form-group - = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control' - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :throttle_authenticated_api_enabled do - = f.check_box :throttle_authenticated_api_enabled - Enable authenticated API request rate limit - %span.help-block - Helps reduce request volume (e.g. from crawlers or abusive bots) - .form-group - = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control' - .form-group - = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control' - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :throttle_authenticated_web_enabled do - = f.check_box :throttle_authenticated_web_enabled - Enable authenticated web request rate limit - %span.help-block - Helps reduce request volume (e.g. from crawlers or abusive bots) - .form-group - = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control' - .form-group - = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control' - - %fieldset - %legend Outbound requests - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :allow_local_requests_from_hooks_and_services do - = f.check_box :allow_local_requests_from_hooks_and_services - Allow requests to the local network from hooks and services - .form-actions = f.submit 'Save', class: 'btn btn-save' diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml new file mode 100644 index 00000000000..b83ffc375d9 --- /dev/null +++ b/app/views/admin/application_settings/_ip_limits.html.haml @@ -0,0 +1,54 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :throttle_unauthenticated_enabled do + = f.check_box :throttle_unauthenticated_enabled + Enable unauthenticated request rate limit + %span.help-block + Helps reduce request volume (e.g. from crawlers or abusive bots) + .form-group + = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control' + .form-group + = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control' + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :throttle_authenticated_api_enabled do + = f.check_box :throttle_authenticated_api_enabled + Enable authenticated API request rate limit + %span.help-block + Helps reduce request volume (e.g. from crawlers or abusive bots) + .form-group + = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control' + .form-group + = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control' + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :throttle_authenticated_web_enabled do + = f.check_box :throttle_authenticated_web_enabled + Enable authenticated web request rate limit + %span.help-block + Helps reduce request volume (e.g. from crawlers or abusive bots) + .form-group + = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control' + .form-group + = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_logging.html.haml b/app/views/admin/application_settings/_logging.html.haml new file mode 100644 index 00000000000..44a11ddc120 --- /dev/null +++ b/app/views/admin/application_settings/_logging.html.haml @@ -0,0 +1,36 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :sentry_enabled do + = f.check_box :sentry_enabled + Enable Sentry + .help-block + %p This setting requires a restart to take effect. + Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: + %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com + + .form-group + = f.label :sentry_dsn, 'Sentry DSN', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :sentry_dsn, class: 'form-control' + + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :clientside_sentry_enabled do + = f.check_box :clientside_sentry_enabled + Enable Clientside Sentry + .help-block + Sentry can also be used for reporting and logging clientside exceptions. + %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ + + .form-group + = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :clientside_sentry_dsn, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml new file mode 100644 index 00000000000..d10f609006d --- /dev/null +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -0,0 +1,12 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :allow_local_requests_from_hooks_and_services do + = f.check_box :allow_local_requests_from_hooks_and_services + Allow requests to the local network from hooks and services + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml new file mode 100644 index 00000000000..5344f030c97 --- /dev/null +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -0,0 +1,16 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :performance_bar_enabled do + = f.check_box :performance_bar_enabled + Enable the Performance Bar + .form-group + = f.label :performance_bar_allowed_group_id, 'Allowed group', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :performance_bar_allowed_group_id, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml new file mode 100644 index 00000000000..f33769b23c2 --- /dev/null +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -0,0 +1,62 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .sub-section + %h4 Repository checks + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :repository_checks_enabled do + = f.check_box :repository_checks_enabled + Enable Repository Checks + .help-block + GitLab will periodically run + %a{ href: 'https://www.kernel.org/pub/software/scm/git/docs/git-fsck.html', target: 'blank' } 'git fsck' + in all project and wiki repositories to look for silent disk corruption issues. + .form-group + .col-sm-offset-2.col-sm-10 + = link_to 'Clear all repository checks', clear_repository_check_states_admin_application_settings_path, data: { confirm: 'This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?' }, method: :put, class: "btn btn-sm btn-remove" + .help-block + If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. + + .sub-section + %h4 Housekeeping + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :housekeeping_enabled do + = f.check_box :housekeeping_enabled + Enable automatic repository housekeeping (git repack, git gc) + .help-block + If you keep automatic housekeeping disabled for a long time Git + repository access on your GitLab server will become slower and your + repositories will use more disk space. We recommend to always leave + this enabled. + .checkbox + = f.label :housekeeping_bitmaps_enabled do + = f.check_box :housekeeping_bitmaps_enabled + Enable Git pack file bitmap creation + .help-block + Creating pack file bitmaps makes housekeeping take a little longer but + bitmaps should accelerate 'git clone' performance. + .form-group + = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :housekeeping_incremental_repack_period, class: 'form-control' + .help-block + Number of Git pushes after which an incremental 'git repack' is run. + .form-group + = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :housekeeping_full_repack_period, class: 'form-control' + .help-block + Number of Git pushes after which a full 'git repack' is run. + .form-group + = f.label :housekeeping_gc_period, 'Git GC period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :housekeeping_gc_period, class: 'form-control' + .help-block + Number of Git pushes after which 'git gc' is run. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml new file mode 100644 index 00000000000..ac31977e1a9 --- /dev/null +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -0,0 +1,58 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .sub-section + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :hashed_storage_enabled do + = f.check_box :hashed_storage_enabled + Create new projects using hashed storage paths + .help-block + Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents + repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance. + %em (EXPERIMENTAL) + .form-group + = f.label :repository_storages, 'Storage paths for new projects', class: 'control-label col-sm-2' + .col-sm-10 + = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages), + {include_hidden: false}, multiple: true, class: 'form-control' + .help-block + Manage repository storage paths. Learn more in the + = succeed "." do + = link_to "repository storages documentation", help_page_path("administration/repository_storages") + .sub-section + %h4 Circuit breaker + .form-group + = f.label :circuitbreaker_check_interval, _('Check interval'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_check_interval, class: 'form-control' + .help-block + = circuitbreaker_check_interval_help_text + .form-group + = f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_access_retries, class: 'form-control' + .help-block + = circuitbreaker_access_retries_help_text + .form-group + = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_storage_timeout, class: 'form-control' + .help-block + = circuitbreaker_storage_timeout_help_text + .form-group + = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control' + .help-block + = circuitbreaker_failure_count_help_text + .form-group + = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control' + .help-block + = circuitbreaker_failure_reset_time_help_text + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml new file mode 100644 index 00000000000..25e89097dfe --- /dev/null +++ b/app/views/admin/application_settings/_spam.html.haml @@ -0,0 +1,65 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :recaptcha_enabled do + = f.check_box :recaptcha_enabled + Enable reCAPTCHA + %span.help-block#recaptcha_help_block Helps prevent bots from creating accounts + + .form-group + = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :recaptcha_site_key, class: 'form-control' + .help-block + Generate site and private keys at + %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha + + .form-group + = f.label :recaptcha_private_key, 'reCAPTCHA Private Key', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :recaptcha_private_key, class: 'form-control' + + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :akismet_enabled do + = f.check_box :akismet_enabled + Enable Akismet + %span.help-block#akismet_help_block Helps prevent bots from creating issues + + .form-group + = f.label :akismet_api_key, 'Akismet API Key', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :akismet_api_key, class: 'form-control' + .help-block + Generate API key at + %a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com + + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :unique_ips_limit_enabled do + = f.check_box :unique_ips_limit_enabled + Limit sign in from multiple ips + %span.help-block#unique_ip_help_block + Helps prevent malicious users hide their activity + + .form-group + = f.label :unique_ips_limit_per_user, 'IPs per user', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :unique_ips_limit_per_user, class: 'form-control' + .help-block + Maximum number of unique IPs per user + + .form-group + = f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :unique_ips_limit_time_window, class: 'form-control' + .help-block + How many seconds an IP will be counted towards the limit + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index 17f2f37d24e..f4320513aff 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -7,7 +7,7 @@ .settings-header %h4 = _('Visibility and access controls') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('Set default and restrict visibility levels. Configure import sources and git access protocol.') @@ -18,7 +18,7 @@ .settings-header %h4 = _('Account and limit settings') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('Session expiration, projects limit and attachment size.') @@ -29,7 +29,7 @@ .settings-header %h4 = _('Sign-up restrictions') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('Configure the way a user creates a new account.') @@ -40,7 +40,7 @@ .settings-header %h4 = _('Sign-in restrictions') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('Set requirements for a user to sign-in. Enable mandatory two-factor authentication.') @@ -51,7 +51,7 @@ .settings-header %h4 = _('Help page') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('Help page text and support page url.') @@ -62,7 +62,7 @@ .settings-header %h4 = _('Pages') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('Size and domain settings for static websites') @@ -73,7 +73,7 @@ .settings-header %h4 = _('Continuous Integration and Deployment') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('Auto DevOps, runners amd job artifacts') @@ -84,7 +84,7 @@ .settings-header %h4 = _('Metrics - Influx') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('Enable and configure InfluxDB metrics.') @@ -95,12 +95,112 @@ .settings-header %h4 = _('Metrics - Prometheus') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('Enable and configure Prometheus metrics.') .settings-content = render 'prometheus' +%section.settings.as-performance.no-animate#js-performance-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Profiling - Performance bar') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Enable the Performance Bar for a given group.') + = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/performance_bar') + .settings-content + = render 'performance_bar' + +%section.settings.as-background.no-animate#js-background-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Background jobs') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure Sidekiq job throttling.') + .settings-content + = render 'background_jobs' + +%section.settings.as-spam.no-animate#js-spam-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Spam and Anti-bot Protection') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Enable reCAPTCHA or Akismet and set IP limits.') + .settings-content + = render 'spam' + +%section.settings.as-abuse.no-animate#js-abuse-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Abuse reports') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Set notification email for abuse reports.') + .settings-content + = render 'abuse' + +%section.settings.as-logging.no-animate#js-logging-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Error Reporting and Logging') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Enable Sentry for error reporting and logging.') + .settings-content + = render 'logging' + +%section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Repository storage') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure storage path and circuit breaker settings.') + .settings-content + = render 'repository_storage' + +%section.settings.as-repository-check.no-animate#js-repository-check-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Repository maintenance') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure automatic git checks and housekeeping on repositories.') + .settings-content + = render 'repository_check' + +%section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('User and IP Rate Limits') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure limits for web and API requests.') + .settings-content + = render 'ip_limits' + +%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Outbound requests') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Allow requests to the local network from hooks and services.') + .settings-content + = render 'outbound' + .prepend-top-20 = render 'form' diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 5d4229c80af..440623b34f5 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -43,7 +43,5 @@ %span.toggle-icon = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') - -# EE-specific start - -# EE-specific end %button.js-row-remove-button.ci-variable-row-remove-button{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } = icon('minus-circle') diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 7f9486d08d9..8e1dea4afc1 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- breadcrumb_title "Details" +- breadcrumb_title _("Details") - can_create_subgroups = can?(current_user, :create_subgroup, @group) = content_for :meta_tags do diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 54ef51b30e3..c63cf2b31cb 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -22,9 +22,6 @@ = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('Personal Access Token'), size: 40 = submit_tag _('List your GitHub repositories'), class: 'btn btn-success' - -# EE-specific start - -# EE-specific end - - unless github_import_configured? %hr %p diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 257f7326409..6513b719199 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html.devise-layout-html +%html.devise-layout-html{ class: system_message_class } = render "layouts/head" %body.ui_indigo.login-page.application.navless{ data: { page: body_data_page } } .page-wrap @@ -16,7 +16,7 @@ %h1 = brand_title = brand_image - - if brand_item&.description? + - if current_appearance&.description? = brand_text - else %h3 Open source software to collaborate on code diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 8718bb3db1a..adf90cb7667 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html{ lang: "en" } +%html{ lang: "en", class: system_message_class } = render "layouts/head" %body.ui_indigo.login-page.application.navless = render "layouts/header/empty" diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 059571f795f..5c90d13420f 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -80,14 +80,6 @@ = link_to charts_project_graph_path(@project, current_ref) do #{ _('Charts') } - - if project_nav_tab? :container_registry - = nav_link(controller: %w[projects/registry/repositories]) do - = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do - .nav-icon-container - = sprite_icon('disk') - %span.nav-item-name - Registry - - if project_nav_tab? :issues = nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do = link_to project_issues_path(@project), class: 'shortcuts-issues' do @@ -231,6 +223,14 @@ %span Charts + - if project_nav_tab? :container_registry + = nav_link(controller: %w[projects/registry/repositories]) do + = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do + .nav-icon-container + = sprite_icon('disk') + %span.nav-item-name + Registry + - if project_nav_tab? :wiki = nav_link(controller: :wikis) do = link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml index 5cc6f21c0f3..4c507c08ed7 100644 --- a/app/views/notify/push_to_merge_request_email.html.haml +++ b/app/views/notify/push_to_merge_request_email.html.haml @@ -1,7 +1,7 @@ %h3 - New commits were pushed to the merge request + = @updated_by_user.name + pushed new commits to merge request = link_to(@merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)) - by #{@current_user.name} - if @existing_commits.any? - count = @existing_commits.size diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml index d7722e5f41f..553f771f1a6 100644 --- a/app/views/notify/push_to_merge_request_email.text.haml +++ b/app/views/notify/push_to_merge_request_email.text.haml @@ -1,4 +1,4 @@ -New commits were pushed to the merge request #{@merge_request.to_reference} by #{@current_user.name} +#{@updated_by_user.name} pushed new commits to merge request #{@merge_request.to_reference} \ #{url_for(project_merge_request_url(@merge_request.target_project, @merge_request))} \ diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 5dfe973f33c..825bfd0707f 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -7,7 +7,7 @@ .settings-header %h4 Export project - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page. diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index 2ee0eafcf1a..4c510293204 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -31,7 +31,7 @@ %section.settings#js-cluster-details{ class: ('expanded' if expanded) } .settings-header %h4= s_('ClusterIntegration|Kubernetes cluster details') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster') .settings-content @@ -43,7 +43,7 @@ %section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) } .settings-header %h4= _('Advanced settings') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration") .settings-content diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 75dd4c9ae15..7dd8dc28e5b 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -3,7 +3,7 @@ .settings-header %h4 Deploy Keys - %button.btn.js-settings-toggle.qa-expand-deploy-keys + %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' } = expanded ? 'Collapse' : '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. diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index a96485ab155..99eeb9551e3 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -8,7 +8,7 @@ .settings-header %h4 General project settings - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Update your project name, description, avatar, and other general settings. @@ -64,7 +64,7 @@ .settings-header %h4 Permissions - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Enable or disable certain project features and choose access levels. @@ -79,7 +79,7 @@ .settings-header %h4 Merge request settings - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Customize your merge request restrictions. @@ -94,7 +94,7 @@ .settings-header %h4 Advanced settings - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Perform advanced options such as housekeeping, archiving, renaming, transferring, or removing your project. diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 8a36fada389..b15fe514a08 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- breadcrumb_title "Details" +- breadcrumb_title _("Details") = render partial: 'flash_messages', locals: { project: @project } diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 1db01133900..29f999da5d2 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -96,4 +96,4 @@ .js-build-options{ data: javascript_build_options } -#js-job-details-vue{ data: { endpoint: project_job_path(@project, @build, format: :json) } } +#js-job-details-vue{ data: { endpoint: project_job_path(@project, @build, format: :json), runner_help_url: help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner') } } diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index b423888c875..5ec219fdf00 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -30,6 +30,7 @@ %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal', target: '#promote-milestone-modal', milestone_title: @milestone.title, + group_name: @project.group.name, url: promote_project_milestone_path(@milestone.project, @milestone), container: 'body' }, disabled: true, diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 8cdb0a6aff4..b66e0559603 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -18,8 +18,6 @@ = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link } %p = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.') - -# EE-specific start - -# EE-specific end .md = brand_new_project_guidelines %p @@ -43,8 +41,6 @@ %a{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab' }, role: 'tab' } %span.hidden-xs Import project %span.visible-xs Import - -# EE-specific start - -# EE-specific end .tab-content.gitlab-tab-content .tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' } @@ -110,10 +106,6 @@ = render "shared/import_form", f: f = render 'new_project_fields', f: f, project_name_id: "import-url-name" - - -# EE-specific start - -# EE-specific end - .save-project-loader.hide .center %h2 diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml index ba5845877e5..14d880028c7 100644 --- a/app/views/projects/no_repo.html.haml +++ b/app/views/projects/no_repo.html.haml @@ -1,3 +1,5 @@ +- breadcrumb_title _("Details") + %h2 %i.fa.fa-warning #{ _('No repository') } @@ -10,7 +12,7 @@ .no-repo-actions = link_to project_repository_path(@project), method: :post, class: 'btn btn-primary' do - #{ _('Create empty bare repository') } + #{ _('Create empty repository') } %strong.prepend-left-10.append-right-10 or @@ -19,4 +21,4 @@ - 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-inverted btn-remove pull-right" diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml index 2a0704bc7af..a09c13176c3 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml @@ -2,7 +2,7 @@ - if @protected_branches.empty? .panel-heading %h3.panel-title - Protected branch (#{@protected_branches.size}) + Protected branch (#{@protected_branches_count}) %p.settings-message.text-center There are currently no protected branches, protect a branch with the form above. - else @@ -16,7 +16,7 @@ %col %thead %tr - %th Protected branch (#{@protected_branches.size}) + %th Protected branch (#{@protected_branches_count}) %th Last commit %th Allowed to merge %th Allowed to push diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index e662b877fbb..55d87c35a80 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -4,7 +4,7 @@ .settings-header %h4 Protected Branches - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Keep stable branches secure and force developers to use merge requests. diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index 24baf1cfc89..c33723d8072 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -4,7 +4,7 @@ .settings-header %h4 Protected Tags - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Limit access to creating and updating tags. diff --git a/app/views/projects/protected_tags/shared/_tags_list.html.haml b/app/views/projects/protected_tags/shared/_tags_list.html.haml index 3f42ae58438..02908e16dc5 100644 --- a/app/views/projects/protected_tags/shared/_tags_list.html.haml +++ b/app/views/projects/protected_tags/shared/_tags_list.html.haml @@ -2,7 +2,7 @@ - if @protected_tags.empty? .panel-heading %h3.panel-title - Protected tag (#{@protected_tags.size}) + Protected tag (#{@protected_tags_count}) %p.settings-message.text-center There are currently no protected tags, protect a tag with the form above. - else @@ -17,7 +17,7 @@ %col %thead %tr - %th Protected tag (#{@protected_tags.size}) + %th Protected tag (#{@protected_tags_count}) %th Last commit %th Allowed to create - if can_admin_project diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml index 49c90869146..6a681736b6f 100644 --- a/app/views/projects/runners/_form.html.haml +++ b/app/views/projects/runners/_form.html.haml @@ -40,6 +40,12 @@ .col-sm-10 = f.text_field :description, class: 'form-control' .form-group + = label_tag :maximum_timeout_human_readable, class: 'control-label' do + Maximum job timeout + .col-sm-10 + = f.text_field :maximum_timeout_human_readable, class: 'form-control' + .help-block This timeout will take precedence when lower than Project-defined timeout + .form-group = label_tag :tag_list, class: 'control-label' do Tags .col-sm-10 diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml index 4e57f5f844d..f33e7e25b68 100644 --- a/app/views/projects/runners/show.html.haml +++ b/app/views/projects/runners/show.html.haml @@ -56,6 +56,9 @@ %td Description %td= @runner.description %tr + %td Maximum job timeout + %td= @runner.maximum_timeout_human_readable + %tr %td Last contact %td - if @runner.contacted_at diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 756f31f91d9..d65341dbd40 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -8,7 +8,7 @@ .settings-header %h4 General pipelines settings - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Update your CI/CD configuration, like job timeout or Auto DevOps. @@ -19,7 +19,7 @@ .settings-header %h4 Runners settings - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Register and see your runners for this project. @@ -31,7 +31,7 @@ %h4 = _('Secret variables') = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p.append-bottom-0 = render "ci/variables/content" @@ -42,7 +42,7 @@ .settings-header %h4 Pipeline triggers - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index fa281327eb7..94331a16abd 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- breadcrumb_title "Details" +- breadcrumb_title _("Details") - @content_class = "limit-container-width" unless fluid_layout - show_auto_devops_callout = show_auto_devops_callout?(@project) diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index 5eaaa1448d5..3806ead6c87 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -17,6 +17,3 @@ = import_will_timeout_message(ci_cd_only) %li = import_svn_message(ci_cd_only) - --# EE-specific start --# EE-specific end diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 5afbc78df53..56403907844 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -55,6 +55,7 @@ label_title: label.title, label_color: label.color, label_text_color: label.text_color, + group_name: label.project.group.name, target: '#promote-label-modal', container: 'body', toggle: 'modal' } } diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index adaddda13eb..6afcd447f28 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -84,7 +84,7 @@ = dropdown_content do .js-due-date-calendar - - if @labels && @labels.any? + - if @labels - selected_labels = issuable.labels .block.labels .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(issuable.labels_array), data: { placement: "left", container: "body" } } diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 5926867e2d7..ac494814f55 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -56,6 +56,7 @@ type: 'button', data: { url: promote_project_milestone_path(milestone.project, milestone), milestone_title: milestone.title, + group_name: @project.group.name, target: '#promote-milestone-modal', container: 'body', toggle: 'modal' } } diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 6006ab8b43f..f302299eb24 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -1,4 +1,5 @@ -- page_title milestone.title, "Milestones" +- page_title @milestone.title +- @breadcrumb_link = dashboard_milestone_path(milestone.safe_title, title: milestone.title) - group = local_assigns[:group] @@ -17,7 +18,7 @@ Milestone #{milestone.title} - if milestone.due_date || milestone.start_date %span.creator - · + · = milestone_date_range(milestone) - if group .pull-right diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb index 01ed123e6c8..a6b2c251254 100644 --- a/app/workers/object_storage/migrate_uploads_worker.rb +++ b/app/workers/object_storage/migrate_uploads_worker.rb @@ -138,21 +138,18 @@ module ObjectStorage include Report - def self.enqueue!(uploads, mounted_as, to_store) - sanity_check!(uploads, mounted_as) + def self.enqueue!(uploads, model_class, mounted_as, to_store) + sanity_check!(uploads, model_class, mounted_as) - perform_async(uploads.ids, mounted_as, to_store) + perform_async(uploads.ids, model_class.to_s, mounted_as, to_store) end # We need to be sure all the uploads are for the same uploader and model type # and that the mount point exists if provided. # - def self.sanity_check!(uploads, mounted_as) + def self.sanity_check!(uploads, model_class, mounted_as) upload = uploads.first - uploader_class = upload.uploader.constantize - model_class = uploads.first.model_type.constantize - uploader_types = uploads.map(&:uploader).uniq model_types = uploads.map(&:model_type).uniq model_has_mount = mounted_as.nil? || model_class.uploaders[mounted_as] == uploader_class @@ -162,7 +159,12 @@ module ObjectStorage raise(SanityCheckError, "Mount point #{mounted_as} not found in #{model_class}.") unless model_has_mount end - def perform(ids, mounted_as, to_store) + def perform(*args) + args_check!(args) + + (ids, model_type, mounted_as, to_store) = args + + @model_class = model_type.constantize @mounted_as = mounted_as&.to_sym @to_store = to_store @@ -178,7 +180,17 @@ module ObjectStorage end def sanity_check!(uploads) - self.class.sanity_check!(uploads, @mounted_as) + self.class.sanity_check!(uploads, @model_class, @mounted_as) + end + + def args_check!(args) + return if args.count == 4 + + case args.count + when 3 then raise SanityCheckError, "Job is missing the `model_type` argument." + else + raise SanityCheckError, "Job has wrong arguments format." + end end def build_uploaders(uploads) diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index 0b502143e5d..c3d84bb0b93 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -4,11 +4,19 @@ class ProjectExportWorker sidekiq_options retry: 3 - def perform(current_user_id, project_id, params = {}) - params = params.with_indifferent_access + def perform(current_user_id, project_id, after_export_strategy = {}, params = {}) current_user = User.find(current_user_id) project = Project.find(project_id) + after_export = build!(after_export_strategy) - ::Projects::ImportExport::ExportService.new(project, current_user, params).execute + ::Projects::ImportExport::ExportService.new(project, current_user, params).execute(after_export) + end + + private + + def build!(after_export_strategy) + strategy_klass = after_export_strategy&.delete('klass') + + Gitlab::ImportExport::AfterExportStrategyBuilder.build!(strategy_klass, after_export_strategy) end end diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 712a63af532..51fad4faf36 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -1,28 +1,50 @@ -# Gitaly issue: https://gitlab.com/gitlab-org/gitaly/issues/1110 class RepositoryForkWorker include ApplicationWorker include Gitlab::ShellAdapter include ProjectStartImport include ProjectImportOptions - def perform(project_id, forked_from_repository_storage_path, source_disk_path) - project = Project.find(project_id) + def perform(*args) + target_project_id = args.shift + target_project = Project.find(target_project_id) - return unless start_fork(project) + # By v10.8, we should've drained the queue of all jobs using the old arguments. + # We can remove the else clause if we're no longer logging the message in that clause. + # See https://gitlab.com/gitlab-org/gitaly/issues/1110 + if args.empty? + source_project = target_project.forked_from_project + return target_project.mark_import_as_failed('Source project cannot be found.') unless source_project - Gitlab::Metrics.add_event(:fork_repository, - source_path: source_disk_path, - target_path: project.disk_path) + fork_repository(target_project, source_project.repository_storage, source_project.disk_path) + else + Rails.logger.info("Project #{target_project.id} is being forked using old-style arguments.") + + source_repository_storage_path, source_disk_path = *args - result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_disk_path, - project.repository_storage_path, project.disk_path) - raise "Unable to fork project #{project_id} for repository #{source_disk_path} -> #{project.disk_path}" unless result + source_repository_storage_name = Gitlab.config.repositories.storages.find do |_, info| + info.legacy_disk_path == source_repository_storage_path + end&.first || raise("no shard found for path '#{source_repository_storage_path}'") - project.after_import + fork_repository(target_project, source_repository_storage_name, source_disk_path) + end end private + def fork_repository(target_project, source_repository_storage_name, source_disk_path) + return unless start_fork(target_project) + + Gitlab::Metrics.add_event(:fork_repository, + source_path: source_disk_path, + target_path: target_project.disk_path) + + result = gitlab_shell.fork_repository(source_repository_storage_name, source_disk_path, + target_project.repository_storage, target_project.disk_path) + raise "Unable to fork project #{target_project.id} for repository #{source_disk_path} -> #{target_project.disk_path}" unless result + + target_project.after_import + end + def start_fork(project) return true if start(project) diff --git a/bin/rspec b/bin/rspec index 6e6709219af..26583242051 100755 --- a/bin/rspec +++ b/bin/rspec @@ -1,4 +1,10 @@ #!/usr/bin/env ruby + +# Remove these two lines below when upgraded to rails 5.0. +# Allow run `rspec` command as `RAILS5=1 rspec ...` instead of `BUNDLE_GEMFILE=Gemfile.rails5 rspec ...` +gemfile = %w[1 true].include?(ENV["RAILS5"]) ? "Gemfile.rails5" : "Gemfile" +ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../#{gemfile}", __dir__) + begin load File.expand_path('../spring', __FILE__) rescue LoadError => e diff --git a/changelogs/unreleased/17516-nested-restore-changelog.yml b/changelogs/unreleased/17516-nested-restore-changelog.yml new file mode 100644 index 00000000000..89753f45457 --- /dev/null +++ b/changelogs/unreleased/17516-nested-restore-changelog.yml @@ -0,0 +1,5 @@ +--- +title: Enable restore rake task to handle nested storage directories +merge_request: 17516 +author: Balasankar C +type: fixed diff --git a/changelogs/unreleased/20394-protected-branches-wildcard.yml b/changelogs/unreleased/20394-protected-branches-wildcard.yml new file mode 100644 index 00000000000..3fa8ee4f69f --- /dev/null +++ b/changelogs/unreleased/20394-protected-branches-wildcard.yml @@ -0,0 +1,5 @@ +--- +title: Include matching branches and tags in protected branches / tags count +merge_request: +author: Jan Beckmann +type: fixed diff --git a/changelogs/unreleased/39880-merge-method-api.yml b/changelogs/unreleased/39880-merge-method-api.yml new file mode 100644 index 00000000000..dd44a752c4f --- /dev/null +++ b/changelogs/unreleased/39880-merge-method-api.yml @@ -0,0 +1,5 @@ +--- +title: 'API: Add parameter merge_method to projects' +merge_request: 18031 +author: Jan Beckmann +type: added diff --git a/changelogs/unreleased/41224-pipeline-icons.yml b/changelogs/unreleased/41224-pipeline-icons.yml new file mode 100644 index 00000000000..3fe05448d1c --- /dev/null +++ b/changelogs/unreleased/41224-pipeline-icons.yml @@ -0,0 +1,5 @@ +--- +title: Increase dropdown width in pipeline graph & center action icon +merge_request: 18089 +author: +type: fixed diff --git a/changelogs/unreleased/41967_issue_api_closed_by_info.yml b/changelogs/unreleased/41967_issue_api_closed_by_info.yml new file mode 100644 index 00000000000..436574c3638 --- /dev/null +++ b/changelogs/unreleased/41967_issue_api_closed_by_info.yml @@ -0,0 +1,5 @@ +--- +title: adds closed by informations in issue api +merge_request: 17042 +author: haseebeqx +type: added diff --git a/changelogs/unreleased/43745-store-metadata-checksum-for-artifacts.yml b/changelogs/unreleased/43745-store-metadata-checksum-for-artifacts.yml new file mode 100644 index 00000000000..6283e797930 --- /dev/null +++ b/changelogs/unreleased/43745-store-metadata-checksum-for-artifacts.yml @@ -0,0 +1,5 @@ +--- +title: Store sha256 checksum of artifact metadata +merge_request: 18149 +author: +type: added diff --git a/changelogs/unreleased/44291-usage-ping-for-kubernetes-integration.yml b/changelogs/unreleased/44291-usage-ping-for-kubernetes-integration.yml new file mode 100644 index 00000000000..b5c12d8f40e --- /dev/null +++ b/changelogs/unreleased/44291-usage-ping-for-kubernetes-integration.yml @@ -0,0 +1,5 @@ +--- +title: Add additional cluster usage metrics to usage ping. +merge_request: 17922 +author: +type: changed diff --git a/changelogs/unreleased/44392-resolve-projects-creation-silently-failing-on-after-create-error.yml b/changelogs/unreleased/44392-resolve-projects-creation-silently-failing-on-after-create-error.yml new file mode 100644 index 00000000000..3bbd5a05b98 --- /dev/null +++ b/changelogs/unreleased/44392-resolve-projects-creation-silently-failing-on-after-create-error.yml @@ -0,0 +1,5 @@ +--- +title: Project creation will now raise an error if a service template is invalid +merge_request: 18013 +author: +type: fixed diff --git a/changelogs/unreleased/44425-use-gitlab_environment.yml b/changelogs/unreleased/44425-use-gitlab_environment.yml new file mode 100644 index 00000000000..a774143d5f5 --- /dev/null +++ b/changelogs/unreleased/44425-use-gitlab_environment.yml @@ -0,0 +1,5 @@ +--- +title: Fix `gitlab-rake gitlab:two_factor:disable_for_all_users` +merge_request: 18154 +author: +type: fixed diff --git a/changelogs/unreleased/44508-fix-fork-namespace-images.yml b/changelogs/unreleased/44508-fix-fork-namespace-images.yml new file mode 100644 index 00000000000..63b4b9a5e56 --- /dev/null +++ b/changelogs/unreleased/44508-fix-fork-namespace-images.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug rendering group icons when forking +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/44587-autolinking-includes-trailing-exclamation-marks.yml b/changelogs/unreleased/44587-autolinking-includes-trailing-exclamation-marks.yml deleted file mode 100644 index 636fde601ee..00000000000 --- a/changelogs/unreleased/44587-autolinking-includes-trailing-exclamation-marks.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Don't capture trailing punctuation when autolinking -merge_request: 17965 -author: -type: fixed diff --git a/changelogs/unreleased/44608-Cloning-a-repository-over-HTTPS-with-LDAP-credentials-causes-a-HTTP-401-Access-denied.yml b/changelogs/unreleased/44608-Cloning-a-repository-over-HTTPS-with-LDAP-credentials-causes-a-HTTP-401-Access-denied.yml deleted file mode 100644 index 5afb1e3d908..00000000000 --- a/changelogs/unreleased/44608-Cloning-a-repository-over-HTTPS-with-LDAP-credentials-causes-a-HTTP-401-Access-denied.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: 'Cloning a repository over HTTPS with LDAP credentials causes a HTTP 401 Access denied' -merge_request: !17988 -author: Horatiu Eugen Vlad -type: fixed diff --git a/changelogs/unreleased/44657-reuse-root_ref_hash-on-branches.yml b/changelogs/unreleased/44657-reuse-root_ref_hash-on-branches.yml new file mode 100644 index 00000000000..4f21aadd86b --- /dev/null +++ b/changelogs/unreleased/44657-reuse-root_ref_hash-on-branches.yml @@ -0,0 +1,5 @@ +--- +title: Reuse root_ref_hash for performance on Branches +merge_request: 17998 +author: Takuya Noguchi +type: performance diff --git a/changelogs/unreleased/44717-no-resolve-issue.yml b/changelogs/unreleased/44717-no-resolve-issue.yml new file mode 100644 index 00000000000..ce23f4e6e9f --- /dev/null +++ b/changelogs/unreleased/44717-no-resolve-issue.yml @@ -0,0 +1,5 @@ +--- +title: Don't show Jump to Discussion button on Issues +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/44774-migrate-upload-task-fails-for-upload-with-store-nil.yml b/changelogs/unreleased/44774-migrate-upload-task-fails-for-upload-with-store-nil.yml new file mode 100644 index 00000000000..372f4293964 --- /dev/null +++ b/changelogs/unreleased/44774-migrate-upload-task-fails-for-upload-with-store-nil.yml @@ -0,0 +1,5 @@ +--- +title: Fixed gitlab:uploads:migrate task ignoring some uploads. +merge_request: 18082 +author: +type: fixed diff --git a/changelogs/unreleased/44776-fix-upload-migrate-fails-for-group.yml b/changelogs/unreleased/44776-fix-upload-migrate-fails-for-group.yml new file mode 100644 index 00000000000..6094fcd0b3e --- /dev/null +++ b/changelogs/unreleased/44776-fix-upload-migrate-fails-for-group.yml @@ -0,0 +1,5 @@ +--- +title: Fixed gitlab:uploads:migrate task failing for Groups' avatar. +merge_request: 18088 +author: +type: fixed diff --git a/changelogs/unreleased/44878-update-brakeman-3-6-1-to-4-2-1.yml b/changelogs/unreleased/44878-update-brakeman-3-6-1-to-4-2-1.yml new file mode 100644 index 00000000000..f5710cf4f7f --- /dev/null +++ b/changelogs/unreleased/44878-update-brakeman-3-6-1-to-4-2-1.yml @@ -0,0 +1,5 @@ +--- +title: Update brakeman 3.6.1 to 4.2.1 +merge_request: 18122 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/44902-remove-rake-test-ci.yml b/changelogs/unreleased/44902-remove-rake-test-ci.yml new file mode 100644 index 00000000000..459de1c2ca3 --- /dev/null +++ b/changelogs/unreleased/44902-remove-rake-test-ci.yml @@ -0,0 +1,5 @@ +--- +title: Remove test_ci rake task +merge_request: 18139 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/Link_to_project_labels_page.yml b/changelogs/unreleased/Link_to_project_labels_page.yml new file mode 100644 index 00000000000..7bdeec423fc --- /dev/null +++ b/changelogs/unreleased/Link_to_project_labels_page.yml @@ -0,0 +1,5 @@ +--- +title: Always display Labels section in issuable sidebar, even when the project has no labels +merge_request: 18081 +author: Branka Martinovic +type: fixed diff --git a/changelogs/unreleased/ac-fix-use_file-race.yml b/changelogs/unreleased/ac-fix-use_file-race.yml new file mode 100644 index 00000000000..f1315d5d50e --- /dev/null +++ b/changelogs/unreleased/ac-fix-use_file-race.yml @@ -0,0 +1,5 @@ +--- +title: Fix data race between ObjectStorage background_upload and Pages publishing +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/ac-pages-port.yml b/changelogs/unreleased/ac-pages-port.yml new file mode 100644 index 00000000000..4f7257b4798 --- /dev/null +++ b/changelogs/unreleased/ac-pages-port.yml @@ -0,0 +1,5 @@ +--- +title: Add missing port to artifact links +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/add-canary-favicon.yml b/changelogs/unreleased/add-canary-favicon.yml new file mode 100644 index 00000000000..1af6572588d --- /dev/null +++ b/changelogs/unreleased/add-canary-favicon.yml @@ -0,0 +1,5 @@ +--- +title: Add yellow favicon when `CANARY=true` to differientate canary environment +merge_request: 12477 +author: +type: changed diff --git a/changelogs/unreleased/add-milestone-path-to-dashboard-milestones-breadcrumb-link.yml b/changelogs/unreleased/add-milestone-path-to-dashboard-milestones-breadcrumb-link.yml new file mode 100644 index 00000000000..015bee99170 --- /dev/null +++ b/changelogs/unreleased/add-milestone-path-to-dashboard-milestones-breadcrumb-link.yml @@ -0,0 +1,5 @@ +--- +title: Update dashboard milestones breadcrumb link +merge_request: 17933 +author: George Tsiolis +type: fixed diff --git a/changelogs/unreleased/add-per-runner-job-timeout.yml b/changelogs/unreleased/add-per-runner-job-timeout.yml new file mode 100644 index 00000000000..336b4d15ddf --- /dev/null +++ b/changelogs/unreleased/add-per-runner-job-timeout.yml @@ -0,0 +1,5 @@ +--- +title: Add per-runner configured job timeout +merge_request: 17221 +author: +type: added diff --git a/changelogs/unreleased/blackst0ne-bump-html-pipeline-gem.yml b/changelogs/unreleased/blackst0ne-bump-html-pipeline-gem.yml new file mode 100644 index 00000000000..9885c8853cc --- /dev/null +++ b/changelogs/unreleased/blackst0ne-bump-html-pipeline-gem.yml @@ -0,0 +1,5 @@ +--- +title: Bump html-pipeline to 2.7.1 +merge_request: 18132 +author: "@blackst0ne" +type: other diff --git a/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-issues-feature.yml b/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-issues-feature.yml new file mode 100644 index 00000000000..7defdc0a28f --- /dev/null +++ b/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-issues-feature.yml @@ -0,0 +1,5 @@ +--- +title: Replace the spinach test with an rspec analog +merge_request: 17950 +author: blackst0ne +type: other diff --git a/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-labels-feature.yml b/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-labels-feature.yml new file mode 100644 index 00000000000..4e1bb15f150 --- /dev/null +++ b/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-labels-feature.yml @@ -0,0 +1,5 @@ +--- +title: Replace the `project/issues/labels.feature` spinach test with an rspec analog +merge_request: 18126 +author: blackst0ne +type: other diff --git a/changelogs/unreleased/dm-deploy-keys-default-user.yml b/changelogs/unreleased/dm-deploy-keys-default-user.yml new file mode 100644 index 00000000000..b82d67d028c --- /dev/null +++ b/changelogs/unreleased/dm-deploy-keys-default-user.yml @@ -0,0 +1,5 @@ +--- +title: Ensure hooks run when a deploy key without a user pushes +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/escape-autocomplete-values-for-markdown.yml b/changelogs/unreleased/escape-autocomplete-values-for-markdown.yml new file mode 100644 index 00000000000..eea9da4c579 --- /dev/null +++ b/changelogs/unreleased/escape-autocomplete-values-for-markdown.yml @@ -0,0 +1,5 @@ +--- +title: Escape Markdown characters properly when using autocomplete +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/feature-gb-variables-expressions-in-only-except.yml b/changelogs/unreleased/feature-gb-variables-expressions-in-only-except.yml new file mode 100644 index 00000000000..84977ce11c8 --- /dev/null +++ b/changelogs/unreleased/feature-gb-variables-expressions-in-only-except.yml @@ -0,0 +1,5 @@ +--- +title: Add support for pipeline variables expressions in only/except +merge_request: 17316 +author: +type: added diff --git a/changelogs/unreleased/fix-gb-fix-background-pipeline-stages-migration.yml b/changelogs/unreleased/fix-gb-fix-background-pipeline-stages-migration.yml new file mode 100644 index 00000000000..63948f0c196 --- /dev/null +++ b/changelogs/unreleased/fix-gb-fix-background-pipeline-stages-migration.yml @@ -0,0 +1,5 @@ +--- +title: Fix exceptions raised when migrating pipeline stages in the background +merge_request: 18076 +author: +type: fixed diff --git a/changelogs/unreleased/fix-projects-no-repository-placeholder.yml b/changelogs/unreleased/fix-projects-no-repository-placeholder.yml new file mode 100644 index 00000000000..3d11c897020 --- /dev/null +++ b/changelogs/unreleased/fix-projects-no-repository-placeholder.yml @@ -0,0 +1,5 @@ +--- +title: Update no repository placeholder +merge_request: 17964 +author: George Tsiolis +type: fixed diff --git a/changelogs/unreleased/fj-174-better-ldap-connection-handling.yml b/changelogs/unreleased/fj-174-better-ldap-connection-handling.yml new file mode 100644 index 00000000000..be0b83505fb --- /dev/null +++ b/changelogs/unreleased/fj-174-better-ldap-connection-handling.yml @@ -0,0 +1,5 @@ +--- +title: Add better LDAP connection handling +merge_request: 18039 +author: +type: fixed diff --git a/changelogs/unreleased/fj-42685-extend-project-export-endpoint.yml b/changelogs/unreleased/fj-42685-extend-project-export-endpoint.yml new file mode 100644 index 00000000000..a06499d821a --- /dev/null +++ b/changelogs/unreleased/fj-42685-extend-project-export-endpoint.yml @@ -0,0 +1,5 @@ +--- +title: Extend API for exporting a project with direct upload URL +merge_request: 17686 +author: +type: added diff --git a/changelogs/unreleased/ide-file-row-hover-style.yml b/changelogs/unreleased/ide-file-row-hover-style.yml new file mode 100644 index 00000000000..158379a5aef --- /dev/null +++ b/changelogs/unreleased/ide-file-row-hover-style.yml @@ -0,0 +1,5 @@ +--- +title: Added hover background color to IDE file list rows +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/jivl-change-copy-text-promote-milestones-labels.yml b/changelogs/unreleased/jivl-change-copy-text-promote-milestones-labels.yml new file mode 100644 index 00000000000..fb3095552d3 --- /dev/null +++ b/changelogs/unreleased/jivl-change-copy-text-promote-milestones-labels.yml @@ -0,0 +1,5 @@ +--- +title: Correct copy text for the promote milestone and label modals +merge_request: 17726 +author: +type: fixed diff --git a/changelogs/unreleased/move-registry-after-cicd-project-nav-sidebar.yml b/changelogs/unreleased/move-registry-after-cicd-project-nav-sidebar.yml new file mode 100644 index 00000000000..03a6fd42228 --- /dev/null +++ b/changelogs/unreleased/move-registry-after-cicd-project-nav-sidebar.yml @@ -0,0 +1,5 @@ +--- + title: Move 'Registry' after 'CI/CD' in project navigation sidebar + merge_request: 18018 + author: Elias Werberich + type: changed diff --git a/changelogs/unreleased/sh-cleanup-pages-worker.yml b/changelogs/unreleased/sh-cleanup-pages-worker.yml new file mode 100644 index 00000000000..c26e1342dd2 --- /dev/null +++ b/changelogs/unreleased/sh-cleanup-pages-worker.yml @@ -0,0 +1,5 @@ +--- +title: Free open file descriptors and libgit2 buffers in UpdatePagesService +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/sh-move-sidekiq-exporter-logs.yml b/changelogs/unreleased/sh-move-sidekiq-exporter-logs.yml new file mode 100644 index 00000000000..1990f4f6124 --- /dev/null +++ b/changelogs/unreleased/sh-move-sidekiq-exporter-logs.yml @@ -0,0 +1,5 @@ +--- +title: Move Sidekiq exporter logs to log/sidekiq_exporter.log +merge_request: +author: +type: other diff --git a/changelogs/unreleased/zj-bump-gitaly.yml b/changelogs/unreleased/zj-bump-gitaly.yml new file mode 100644 index 00000000000..eb28bed70e4 --- /dev/null +++ b/changelogs/unreleased/zj-bump-gitaly.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade Gitaly to upgrade its charlock_holmes +merge_request: +author: +type: other diff --git a/changelogs/unreleased/zj-feature-gate-remove-http-api.yml b/changelogs/unreleased/zj-feature-gate-remove-http-api.yml new file mode 100644 index 00000000000..2095f60146c --- /dev/null +++ b/changelogs/unreleased/zj-feature-gate-remove-http-api.yml @@ -0,0 +1,5 @@ +--- +title: Allow feature gates to be removed through the API +merge_request: +author: +type: added diff --git a/config/webpack.config.js b/config/webpack.config.js index b74d9dde494..39e9fbbd530 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -104,7 +104,7 @@ const config = { }, }, { - test: /katex.css$/, + test: /katex.min.css$/, include: /node_modules\/katex\/dist/, use: [ { loader: 'style-loader' }, diff --git a/db/migrate/20180209165249_add_closed_by_to_issues.rb b/db/migrate/20180209165249_add_closed_by_to_issues.rb new file mode 100644 index 00000000000..e251afd7b49 --- /dev/null +++ b/db/migrate/20180209165249_add_closed_by_to_issues.rb @@ -0,0 +1,20 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddClosedByToIssues < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def up + add_column :issues, :closed_by_id, :integer + add_concurrent_foreign_key :issues, :users, column: :closed_by_id, on_delete: :nullify + end + + def down + remove_foreign_key :issues, column: :closed_by_id + remove_column :issues, :closed_by_id + end +end diff --git a/db/migrate/20180219153455_add_maximum_timeout_to_ci_runners.rb b/db/migrate/20180219153455_add_maximum_timeout_to_ci_runners.rb new file mode 100644 index 00000000000..072e696a43e --- /dev/null +++ b/db/migrate/20180219153455_add_maximum_timeout_to_ci_runners.rb @@ -0,0 +1,9 @@ +class AddMaximumTimeoutToCiRunners < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_runners, :maximum_timeout, :integer + end +end diff --git a/db/migrate/20180301010859_create_ci_builds_metadata_table.rb b/db/migrate/20180301010859_create_ci_builds_metadata_table.rb new file mode 100644 index 00000000000..ce737444092 --- /dev/null +++ b/db/migrate/20180301010859_create_ci_builds_metadata_table.rb @@ -0,0 +1,20 @@ +class CreateCiBuildsMetadataTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :ci_builds_metadata do |t| + t.integer :build_id, null: false + t.integer :project_id, null: false + t.integer :timeout + t.integer :timeout_source, null: false, default: 1 + + t.foreign_key :ci_builds, column: :build_id, on_delete: :cascade + t.foreign_key :projects, column: :project_id, on_delete: :cascade + + t.index :build_id, unique: true + t.index :project_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 77b3d836287..06fc1a9d7e9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -329,6 +329,16 @@ ActiveRecord::Schema.define(version: 20180327101207) do add_index "ci_builds", ["updated_at"], name: "index_ci_builds_on_updated_at", using: :btree add_index "ci_builds", ["user_id"], name: "index_ci_builds_on_user_id", using: :btree + create_table "ci_builds_metadata", force: :cascade do |t| + t.integer "build_id", null: false + t.integer "project_id", null: false + t.integer "timeout" + t.integer "timeout_source", default: 1, null: false + end + + add_index "ci_builds_metadata", ["build_id"], name: "index_ci_builds_metadata_on_build_id", unique: true, using: :btree + add_index "ci_builds_metadata", ["project_id"], name: "index_ci_builds_metadata_on_project_id", using: :btree + create_table "ci_group_variables", force: :cascade do |t| t.string "key", null: false t.text "value" @@ -459,6 +469,7 @@ ActiveRecord::Schema.define(version: 20180327101207) do t.boolean "locked", default: false, null: false t.integer "access_level", default: 0, null: false t.string "ip_address" + t.integer "maximum_timeout" end add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree @@ -921,6 +932,7 @@ ActiveRecord::Schema.define(version: 20180327101207) do t.integer "last_edited_by_id" t.boolean "discussion_locked" t.datetime_with_timezone "closed_at" + t.integer "closed_by_id" end add_index "issues", ["author_id"], name: "index_issues_on_author_id", using: :btree @@ -2027,6 +2039,8 @@ ActiveRecord::Schema.define(version: 20180327101207) do 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_builds", "projects", name: "fk_befce0568a", on_delete: :cascade + add_foreign_key "ci_builds_metadata", "ci_builds", column: "build_id", on_delete: :cascade + add_foreign_key "ci_builds_metadata", "projects", on_delete: :cascade add_foreign_key "ci_group_variables", "namespaces", column: "group_id", name: "fk_33ae4d58d8", on_delete: :cascade add_foreign_key "ci_job_artifacts", "ci_builds", column: "job_id", on_delete: :cascade add_foreign_key "ci_job_artifacts", "projects", on_delete: :cascade @@ -2082,6 +2096,7 @@ ActiveRecord::Schema.define(version: 20180327101207) do add_foreign_key "issues", "milestones", name: "fk_96b1dd429c", on_delete: :nullify add_foreign_key "issues", "projects", name: "fk_899c8f3231", on_delete: :cascade add_foreign_key "issues", "users", column: "author_id", name: "fk_05f1e72feb", on_delete: :nullify + add_foreign_key "issues", "users", column: "closed_by_id", name: "fk_c63cbf6c25", on_delete: :nullify add_foreign_key "issues", "users", column: "updated_by_id", name: "fk_ffed080f01", on_delete: :nullify add_foreign_key "label_priorities", "labels", on_delete: :cascade add_foreign_key "label_priorities", "projects", on_delete: :cascade diff --git a/doc/README.md b/doc/README.md index be805a2ccc4..604f7244a34 100644 --- a/doc/README.md +++ b/doc/README.md @@ -211,9 +211,9 @@ straight away. ### GitLab self-hosted -With GitLab self-hosted, you deploy your own GitLab instance on-premises or on a private cloud of your choice. GitLab self-hosted is available for [free and with paid subscriptions](https://about.gitlab.com/products/): Libre, Starter, Premium, and Ultimate. +With GitLab self-hosted, you deploy your own GitLab instance on-premises or on a private cloud of your choice. GitLab self-hosted is available for [free and with paid subscriptions](https://about.gitlab.com/products/): Core, Starter, Premium, and Ultimate. -Every feature available in Libre is also available in Starter, Premium, and Ultimate. +Every feature available in Core is also available in Starter, Premium, and Ultimate. Starter features are also available in Premium and Ultimate, and Premium features are also available in Ultimate. @@ -227,7 +227,7 @@ GitLab.com subscriptions grants access to the same features available in GitLab self-hosted, **expect [administration](administration/index.md) tools and settings**: -- GitLab.com Free includes the same features available in GitLab Libre +- GitLab.com Free includes the same features available in Core - GitLab.com Bronze includes the same features available in GitLab Starter - GitLab.com Silver includes the same features available in GitLab Premium - GitLab.com Gold includes the same features available in GitLab Ultimate diff --git a/doc/administration/img/circuitbreaker_config.png b/doc/administration/img/circuitbreaker_config.png Binary files differindex e811d173634..693b2ee9c69 100644 --- a/doc/administration/img/circuitbreaker_config.png +++ b/doc/administration/img/circuitbreaker_config.png diff --git a/doc/administration/img/repository_storages_admin_ui.png b/doc/administration/img/repository_storages_admin_ui.png Binary files differindex 3e76c5b282c..036e708cdac 100644 --- a/doc/administration/img/repository_storages_admin_ui.png +++ b/doc/administration/img/repository_storages_admin_ui.png diff --git a/doc/administration/index.md b/doc/administration/index.md index 4366590578a..60a45426636 100644 --- a/doc/administration/index.md +++ b/doc/administration/index.md @@ -11,8 +11,8 @@ available through [different subscriptions](https://about.gitlab.com/products/). You can [install GitLab CE or GitLab EE](https://about.gitlab.com/installation/ce-or-ee/), but the features you'll have access to depend on the subscription you choose -(Libre, Starter, Premium, or Ultimate). GitLab Community Edition installations -only have access to Libre features. +(Core, Starter, Premium, or Ultimate). GitLab Community Edition installations +only have access to Core features. GitLab.com is administered by GitLab, Inc., therefore, only GitLab team members have access to its admin configurations. If you're a GitLab.com user, please check the diff --git a/doc/administration/issue_closing_pattern.md b/doc/administration/issue_closing_pattern.md index 28e1fd4e12e..466bb1f851e 100644 --- a/doc/administration/issue_closing_pattern.md +++ b/doc/administration/issue_closing_pattern.md @@ -24,11 +24,11 @@ Because Rubular doesn't understand `%{issue_ref}`, you can replace this by **For Omnibus installations** 1. Open `/etc/gitlab/gitlab.rb` with your editor. -1. Change the value of `gitlab_rails['issue_closing_pattern']` to a regular +1. Change the value of `gitlab_rails['gitlab_issue_closing_pattern']` to a regular expression of your liking: ```ruby - gitlab_rails['issue_closing_pattern'] = "((?:[Cc]los(?:e[sd]|ing)|[Ff]ix(?:e[sd]|ing)?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?))+)" + gitlab_rails['gitlab_issue_closing_pattern'] = "((?:[Cc]los(?:e[sd]|ing)|[Ff]ix(?:e[sd]|ing)?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?))+)" ``` 1. [Reconfigure] GitLab for the changes to take effect. diff --git a/doc/administration/logs.md b/doc/administration/logs.md index 00a2f3d01b8..cd107a5b39c 100644 --- a/doc/administration/logs.md +++ b/doc/administration/logs.md @@ -206,4 +206,12 @@ is populated whenever `gitlab-ctl reconfigure` is run manually or as part of an Reconfigure logs files are named according to the UNIX timestamp of when the reconfigure was initiated, such as `1509705644.log` +## `sidekiq_exporter.log` + +If Prometheus metrics and the Sidekiq Exporter are both enabled, Sidekiq will +start a Web server and listen to the defined port (default: 3807). Access logs +will be generated in `/var/log/gitlab/gitlab-rails/sidekiq_exporter.log` for +Omnibus GitLab packages or in `/home/git/gitlab/log/sidekiq_exporter.log` for +installations from source. + [repocheck]: repository_checks.md diff --git a/doc/api/commits.md b/doc/api/commits.md index a6b96ba539f..db0a80d04d9 100644 --- a/doc/api/commits.md +++ b/doc/api/commits.md @@ -412,9 +412,10 @@ Example response: Since GitLab 8.1, this is the new commit status API. -### Get the status of a commit +### List the statuses of a commit -Get the statuses of a commit in a project. +List the statuses of a commit in a project. +The pagination parameters `page` and `per_page` can be used to restrict the list of references. ``` GET /projects/:id/repository/commits/:sha/statuses diff --git a/doc/api/events.md b/doc/api/events.md index 129af0afa35..f4d26c4de1c 100644 --- a/doc/api/events.md +++ b/doc/api/events.md @@ -42,6 +42,10 @@ Dates for the `before` and `after` parameters should be supplied in the followin YYYY-MM-DD ``` +### Event Time Period Limit + +GitLab removes events older than 1 year from the events table for performance reasons. The range of 1 year was chosen because user contribution calendars only show contributions of the past year. + ## List currently authenticated user's events >**Note:** This endpoint was introduced in GitLab 9.3. diff --git a/doc/api/features.md b/doc/api/features.md index 6861dbf00a2..6ee1c36ef5b 100644 --- a/doc/api/features.md +++ b/doc/api/features.md @@ -86,3 +86,11 @@ Example response: ] } ``` + +## Delete a feature + +Removes a feature gate. Response is equal when the gate exists, or doesn't. + +``` +DELETE /features/:name +``` diff --git a/doc/api/issues.md b/doc/api/issues.md index a4a51101297..7479c1d2f93 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -100,6 +100,7 @@ Example response: }, "updated_at" : "2016-01-04T15:31:51.081Z", "closed_at" : null, + "closed_by" : null, "id" : 76, "title" : "Consequatur vero maxime deserunt laboriosam est voluptas dolorem.", "created_at" : "2016-01-04T15:31:51.081Z", @@ -122,6 +123,8 @@ Example response: **Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. +**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists. + ## List group issues Get a list of a group's issues. @@ -216,6 +219,7 @@ Example response: "updated_at" : "2016-01-04T15:31:46.176Z", "created_at" : "2016-01-04T15:31:46.176Z", "closed_at" : null, + "closed_by" : null, "user_notes_count": 1, "due_date": null, "web_url": "http://example.com/example/example/issues/1", @@ -233,6 +237,8 @@ Example response: **Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. +**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists. + ## List project issues Get a list of a project's issues. @@ -326,6 +332,14 @@ Example response: "updated_at" : "2016-01-04T15:31:46.176Z", "created_at" : "2016-01-04T15:31:46.176Z", "closed_at" : "2016-01-05T15:31:46.176Z", + "closed_by" : { + "state" : "active", + "web_url" : "https://gitlab.example.com/root", + "avatar_url" : null, + "username" : "root", + "id" : 1, + "name" : "Administrator" + }, "user_notes_count": 1, "due_date": "2016-07-22", "web_url": "http://example.com/example/example/issues/1", @@ -343,6 +357,8 @@ Example response: **Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. +**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists. + ## Single issue Get a single project issue. @@ -409,6 +425,8 @@ Example response: "title" : "Ut commodi ullam eos dolores perferendis nihil sunt.", "updated_at" : "2016-01-04T15:31:46.176Z", "created_at" : "2016-01-04T15:31:46.176Z", + "closed_at" : null, + "closed_by" : null, "subscribed": false, "user_notes_count": 1, "due_date": null, @@ -432,6 +450,8 @@ Example response: **Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. +**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists. + ## New issue Creates a new project issue. @@ -484,6 +504,7 @@ Example response: "description" : null, "updated_at" : "2016-01-07T12:44:33.959Z", "closed_at" : null, + "closed_by" : null, "milestone" : null, "subscribed" : true, "user_notes_count": 0, @@ -508,6 +529,8 @@ Example response: **Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. +**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists. + ## Edit issue Updates an existing project issue. This call is also used to mark an issue as @@ -556,6 +579,14 @@ Example response: "description" : null, "updated_at" : "2016-01-07T12:55:16.213Z", "closed_at" : "2016-01-08T12:55:16.213Z", + "closed_by" : { + "state" : "active", + "web_url" : "https://gitlab.example.com/root", + "avatar_url" : null, + "username" : "root", + "id" : 1, + "name" : "Administrator" + }, "iid" : 15, "labels" : [ "bug" @@ -587,6 +618,8 @@ Example response: **Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. +**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists. + ## Delete an issue Only for admins and project owners. Soft deletes the issue in question. @@ -640,6 +673,7 @@ Example response: "created_at": "2016-04-05T21:41:45.652Z", "updated_at": "2016-04-07T12:20:17.596Z", "closed_at": null, + "closed_by": null, "labels": [], "milestone": null, "assignees": [{ @@ -687,6 +721,8 @@ Example response: **Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. +**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists. + ## Subscribe to an issue Subscribes the authenticated user to an issue to receive notifications. @@ -719,6 +755,7 @@ Example response: "created_at": "2016-04-05T21:41:45.652Z", "updated_at": "2016-04-07T12:20:17.596Z", "closed_at": null, + "closed_by": null, "labels": [], "milestone": null, "assignees": [{ @@ -766,6 +803,9 @@ Example response: **Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. + +**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists. + ## Unsubscribe from an issue Unsubscribes the authenticated user from the issue to not receive notifications @@ -807,6 +847,8 @@ Example response: "avatar_url": "http://www.gravatar.com/avatar/3e6f06a86cf27fa8b56f3f74f7615987?s=80&d=identicon", "web_url": "https://gitlab.example.com/keyon" }, + "closed_at": null, + "closed_by": null, "author": { "name": "Vivian Hermann", "username": "orville", @@ -927,6 +969,9 @@ Example response: **Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. + +**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists. + ## Set a time estimate for an issue Sets an estimated time of work for this issue. @@ -1112,6 +1157,8 @@ Example response: "assignee": null, "source_project_id": 1, "target_project_id": 1, + "closed_at": null, + "closed_by": null, "labels": [], "work_in_progress": false, "milestone": null, @@ -1206,3 +1253,4 @@ Example response: [ce-13004]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13004 [ce-14016]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14016 +[ce-17042]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17042 diff --git a/doc/api/jobs.md b/doc/api/jobs.md index e7060e154f4..db4fe2f6880 100644 --- a/doc/api/jobs.md +++ b/doc/api/jobs.md @@ -294,9 +294,10 @@ Example of response ## Get job artifacts -> [Introduced][ce-2893] in GitLab 8.5 +> **Notes**: +- [Introduced][ce-2893] in GitLab 8.5. -Get job artifacts of a project +Get job artifacts of a project. ``` GET /projects/:id/jobs/:job_id/artifacts @@ -307,8 +308,10 @@ GET /projects/:id/jobs/:job_id/artifacts | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `job_id` | integer | yes | The ID of a job | +Example requests: + ``` -curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/jobs/8/artifacts" +curl --location --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/jobs/8/artifacts" ``` Response: @@ -322,7 +325,8 @@ Response: ## Download the artifacts archive -> [Introduced][ce-5347] in GitLab 8.10. +> **Notes**: +- [Introduced][ce-5347] in GitLab 8.10. Download the artifacts archive from the given reference name and job provided the job finished successfully. @@ -339,7 +343,7 @@ Parameters | `ref_name` | string | yes | The ref from a repository (can only be branch or tag name, not HEAD or SHA) | | `job` | string | yes | The name of the job | -Example request: +Example requests: ``` curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/jobs/artifacts/master/download?job=test" diff --git a/doc/api/project_import_export.md b/doc/api/project_import_export.md index de5207fc5e4..5467187788a 100644 --- a/doc/api/project_import_export.md +++ b/doc/api/project_import_export.md @@ -8,6 +8,14 @@ Start a new export. +The endpoint also accepts an `upload` param. This param is a hash that contains +all the necessary information to upload the exported project to a web server or +to any S3-compatible platform. At the moment we only support binary +data file uploads to the final server. + +If the `upload` params is present, `upload[url]` param is required. + (**Note:** This feature was introduced in GitLab 10.7) + ```http POST /projects/:id/export ``` @@ -16,9 +24,12 @@ POST /projects/:id/export | --------- | -------------- | -------- | ---------------------------------------- | | `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 | no | Overrides the project description | +| `upload` | hash | no | Hash that contains the information to upload the exported project to a web server | +| `upload[url]` | string | yes | The URL to upload the project | +| `upload[http_method]` | string | no | The HTTP method to upload the exported project. Only `PUT` and `POST` methods allowed. Default is `PUT` | ```console -curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form "description=Foo Bar" https://gitlab.example.com/api/v4/projects/1/export +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/export --data "description=FooBar&upload[http_method]=PUT&upload[url]=https://example-bucket.s3.eu-west-3.amazonaws.com/backup?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIMBJHN2O62W8IELQ%2F20180312%2Feu-west-3%2Fs3%2Faws4_request&X-Amz-Date=20180312T110328Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=8413facb20ff33a49a147a0b4abcff4c8487cc33ee1f7e450c46e8f695569dbd" ``` ```json @@ -43,7 +54,11 @@ GET /projects/:id/export curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/export ``` -Status can be one of `none`, `started`, or `finished`. +Status can be one of `none`, `started`, `after_export_action` or `finished`. The +`after_export_action` state represents that the export process has been completed successfully and +the platform is performing some actions on the resulted file. For example, sending +an email notifying the user to download the file, uploading the exported file +to a web server, etc. `_links` are only present when export has finished. diff --git a/doc/api/projects.md b/doc/api/projects.md index 271ee91dc72..a0cb5aa0820 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -16,6 +16,21 @@ Values for the project visibility level are: * `public`: The project can be cloned without any authentication. +## Project merge method + +There are currently three options for `merge_method` to choose from: + +* `merge`: + A merge commit is created for every merge, and merging is allowed as long as there are no conflicts. + +* `rebase_merge`: + A merge commit is created for every merge, but merging is only allowed if fast-forward merge is possible. + This way you could make sure that if this merge request would build, after merging to target branch it would also build. + +* `ff`: + No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded. + + ## List all projects Get a list of all visible projects across GitLab for the authenticated user. @@ -94,6 +109,7 @@ GET /projects "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, "request_access_enabled": false, + "merge_method": "merge", "statistics": { "commit_count": 37, "storage_size": 1038090, @@ -173,6 +189,7 @@ GET /projects "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, "request_access_enabled": false, + "merge_method": "merge", "statistics": { "commit_count": 12, "storage_size": 2066080, @@ -278,6 +295,7 @@ GET /users/:user_id/projects "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, "request_access_enabled": false, + "merge_method": "merge", "statistics": { "commit_count": 37, "storage_size": 1038090, @@ -357,6 +375,7 @@ GET /users/:user_id/projects "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, "request_access_enabled": false, + "merge_method": "merge", "statistics": { "commit_count": 12, "storage_size": 2066080, @@ -467,6 +486,7 @@ GET /projects/:id "only_allow_merge_if_all_discussions_are_resolved": false, "printing_merge_requests_link_enabled": true, "request_access_enabled": false, + "merge_method": "merge", "statistics": { "commit_count": 37, "storage_size": 1038090, @@ -550,6 +570,7 @@ POST /projects | `public_jobs` | boolean | no | If `true`, jobs can be viewed by non-project-members | | `only_allow_merge_if_pipeline_succeeds` | boolean | no | Set whether merge requests can only be merged with successful jobs | | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | +| `merge_method` | string | no | Set the merge method used | | `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 | @@ -586,6 +607,7 @@ POST /projects/user/:user_id | `public_jobs` | boolean | no | If `true`, jobs can be viewed by non-project-members | | `only_allow_merge_if_pipeline_succeeds` | boolean | no | Set whether merge requests can only be merged with successful jobs | | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | +| `merge_method` | string | no | Set the merge method used | | `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 | @@ -621,6 +643,7 @@ PUT /projects/:id | `public_jobs` | boolean | no | If `true`, jobs can be viewed by non-project-members | | `only_allow_merge_if_pipeline_succeeds` | boolean | no | Set whether merge requests can only be merged with successful jobs | | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | +| `merge_method` | string | no | Set the merge method used | | `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 | @@ -724,6 +747,7 @@ Example responses: "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, "request_access_enabled": false, + "merge_method": "merge", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -801,6 +825,7 @@ Example response: "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, "request_access_enabled": false, + "merge_method": "merge", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -877,6 +902,7 @@ Example response: "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, "request_access_enabled": false, + "merge_method": "merge", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -971,6 +997,7 @@ Example response: "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, "request_access_enabled": false, + "merge_method": "merge", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1065,6 +1092,7 @@ Example response: "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, "request_access_enabled": false, + "merge_method": "merge", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1344,3 +1372,7 @@ Read more in the [Project members](members.md) documentation. ## Project badges Read more in the [Project Badges](project_badges.md) documentation. + +## Issue and merge request description templates + +The non-default [issue and merge request description templates](../user/project/description_templates.md) are managed inside the project's repository. So you can manage them via the API through the [Repositories API](repositories.md) and the [Repository Files API](repository_files.md).
\ No newline at end of file diff --git a/doc/api/runners.md b/doc/api/runners.md index 7495c6cdedb..f384ac57bfe 100644 --- a/doc/api/runners.md +++ b/doc/api/runners.md @@ -153,7 +153,8 @@ Example response: "mysql" ], "version": null, - "access_level": "ref_protected" + "access_level": "ref_protected", + "maximum_timeout": 3600 } ``` @@ -174,6 +175,7 @@ PUT /runners/:id | `run_untagged` | boolean | no | Flag indicating the runner can execute untagged jobs | | `locked` | boolean | no | Flag indicating the runner is locked | | `access_level` | string | no | The access_level of the runner; `not_protected` or `ref_protected` | +| `maximum_timeout` | integer | no | Maximum timeout set when this Runner will handle the job | ``` curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/runners/6" --form "description=test-1-20150125-test" --form "tag_list=ruby,mysql,tag1,tag2" @@ -211,7 +213,8 @@ Example response: "tag2" ], "version": null, - "access_level": "ref_protected" + "access_level": "ref_protected", + "maximum_timeout": null } ``` diff --git a/doc/ci/examples/browser_performance.md b/doc/ci/examples/browser_performance.md index 42dc6ef36ba..691370d7195 100644 --- a/doc/ci/examples/browser_performance.md +++ b/doc/ci/examples/browser_performance.md @@ -1,22 +1,28 @@ # Browser Performance Testing with the Sitespeed.io container -This example shows how to run the [Sitespeed.io container](https://hub.docker.com/r/sitespeedio/sitespeed.io/) on your code by using -GitLab CI/CD and [Sitespeed.io](https://www.sitespeed.io) using Docker-in-Docker. +This example shows how to run the +[Sitespeed.io container](https://hub.docker.com/r/sitespeedio/sitespeed.io/) on +your code by using GitLab CI/CD and [Sitespeed.io](https://www.sitespeed.io) +using Docker-in-Docker. -First, you need a GitLab Runner with the [docker-in-docker executor](../docker/using_docker_build.md#use-docker-in-docker-executor). - -Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `performance`: +First, you need a GitLab Runner with the +[docker-in-docker executor](../docker/using_docker_build.md#use-docker-in-docker-executor). +Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called +`performance`: ```yaml +performance: stage: performance image: docker:git + variables: + URL: https://example.com services: - docker:dind script: - mkdir gitlab-exporter - - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/10-5/index.js + - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js - mkdir sitespeed-results - - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results https://my.website.com + - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results $URL - mv sitespeed-results/data/performance.json performance.json artifacts: paths: @@ -24,37 +30,84 @@ Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `performan - sitespeed-results/ ``` -This will create a `performance` job in your CI/CD pipeline and will run Sitespeed.io against the webpage you define. The GitLab plugin for Sitespeed.io is downloaded in order to export key metrics to JSON. The full HTML Sitespeed.io report will also be saved as an artifact, and if you have Pages enabled it can be viewed directly in your browser. For further customization options of Sitespeed.io, including the ability to provide a list of URLs to test, please consult their [documentation](https://www.sitespeed.io/documentation/sitespeed.io/configuration/). +The above example will: + +1. Create a `performance` job in your CI/CD pipeline and will run + Sitespeed.io against the webpage you defined in `URL`. +1. The [GitLab plugin](https://gitlab.com/gitlab-org/gl-performance) for + Sitespeed.io is downloaded in order to export key metrics to JSON. The full + HTML Sitespeed.io report will also be saved as an artifact, and if you have + [GitLab Pages](../../user/project/pages/index.md) enabled, it can be viewed + directly in your browser. + +For further customization options of Sitespeed.io, including the ability to +provide a list of URLs to test, please consult +[their documentation](https://www.sitespeed.io/documentation/sitespeed.io/configuration/). -For [GitLab Premium](https://about.gitlab.com/products/) users, key metrics are automatically -extracted and shown right in the merge request widget. Learn more about [Browser Performance Testing](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html). +TIP: **Tip:** +For [GitLab Premium](https://about.gitlab.com/pricing/) users, key metrics are automatically +extracted and shown right in the merge request widget. Learn more about +[Browser Performance Testing](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html). ## Performance testing on Review Apps -The above CI YML is great for testing against static environments, and it can be extended for dynamic environments. There are a few extra steps to take to set this up: -1. The `performance` job should run after the environment has started. -1. In the `deploy` job, persist the hostname so it is available to the `performance` job. The same can be done for static environments like staging and production to unify the code path. Saving it as an artifact is as simple as `echo $CI_ENVIRONMENT_URL > environment_url.txt`. -1. In the `performance` job read the artifact into an environment variable, like `$CI_ENVIRONMENT_URL`, and use it to parameterize the test URL's. -1. Now you can run the Sitespeed.io container against the desired hostname and paths. +The above CI YML is great for testing against static environments, and it can +be extended for dynamic environments. There are a few extra steps to take to +set this up: -A simple `performance` job would look like: +1. The `performance` job should run after the dynamic environment has started. +1. In the `review` job, persist the hostname and upload it as an artifact so + it's available to the `performance` job (the same can be done for static + environments like staging and production to unify the code path). Saving it + as an artifact is as simple as `echo $CI_ENVIRONMENT_URL > environment_url.txt` + in your job's `script`. +1. In the `performance` job, read the previous artifact into an environment + variable, like `$CI_ENVIRONMENT_URL`, and use it to parameterize the test + URLs. +1. You can now run the Sitespeed.io container against the desired hostname and + paths. + +Your `.gitlab-ci.yml` file would look like: ```yaml +stages: + - deploy + - performance + +review: + stage: deploy + environment: + name: review/$CI_COMMIT_REF_SLUG + url: http://$CI_COMMIT_REF_SLUG.$APPS_DOMAIN + script: + - run_deploy_script + - echo $CI_ENVIRONMENT_URL > environment_url.txt + artifacts: + paths: + - environment_url.txt + only: + - branches + except: + - master + +performance: stage: performance image: docker:git services: - docker:dind + dependencies: + - review script: - export CI_ENVIRONMENT_URL=$(cat environment_url.txt) - mkdir gitlab-exporter - - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/10-5/index.js + - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js - mkdir sitespeed-results - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "$CI_ENVIRONMENT_URL" - mv sitespeed-results/data/performance.json performance.json artifacts: paths: - - performance.json - - sitespeed-results/ + - performance.json + - sitespeed-results/ ``` -A complete example can be found in our [Auto DevOps CI YML](https://gitlab.com/gitlab-org/gitlab-ci-yml/blob/master/Auto-DevOps.gitlab-ci.yml).
\ No newline at end of file +A complete example can be found in our [Auto DevOps CI YML](https://gitlab.com/gitlab-org/gitlab-ci-yml/blob/master/Auto-DevOps.gitlab-ci.yml). diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md index ec5e5afb8c6..92317c77427 100644 --- a/doc/ci/examples/code_climate.md +++ b/doc/ci/examples/code_climate.md @@ -9,19 +9,15 @@ Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codequali ```yaml codequality: - image: docker:latest + image: docker:stable variables: - DOCKER_DRIVER: overlay + DOCKER_DRIVER: overlay2 + allow_failure: true services: - - docker:dind + - docker:stable-dind script: - - docker pull codeclimate/codeclimate - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') - - docker run - --env SOURCE_CODE="$PWD" \ - --volume "$PWD":/code \ - --volume /var/run/docker.sock:/var/run/docker.sock \ - "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code + - docker run --env SOURCE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code artifacts: paths: [codeclimate.json] ``` diff --git a/doc/ci/examples/container_scanning.md b/doc/ci/examples/container_scanning.md index 3437b63748a..c58efc7392a 100644 --- a/doc/ci/examples/container_scanning.md +++ b/doc/ci/examples/container_scanning.md @@ -11,7 +11,7 @@ called `sast:container`: ```yaml sast:container: - image: docker:latest + image: docker:stable variables: DOCKER_DRIVER: overlay2 ## Define two new variables based on GitLab's CI/CD predefined variables @@ -20,7 +20,7 @@ sast:container: CI_APPLICATION_TAG: $CI_COMMIT_SHA allow_failure: true services: - - docker:dind + - docker:stable-dind script: - docker run -d --name db arminc/clair-db:latest - docker run -p 6060:6060 --link db:postgres -d --name clair arminc/clair-local-scan:v2.0.1 diff --git a/doc/ci/examples/dast.md b/doc/ci/examples/dast.md index 96de0f5ff5c..8df223ee560 100644 --- a/doc/ci/examples/dast.md +++ b/doc/ci/examples/dast.md @@ -14,9 +14,10 @@ called `dast`: ```yaml dast: - image: owasp/zap2docker-stable + image: registry.gitlab.com/gitlab-org/security-products/zaproxy variables: website: "https://example.com" + allow_failure: true script: - mkdir /zap/wrk/ - /zap/zap-baseline.py -J gl-dast-report.json -t $website || true @@ -30,6 +31,28 @@ the tests on the URL defined in the `website` variable (change it to use your own) and finally write the results in the `gl-dast-report.json` file. You can then download and analyze the report artifact in JSON format. +It's also possible to authenticate the user before performing DAST checks: + +```yaml +dast: + image: registry.gitlab.com/gitlab-org/security-products/zaproxy + variables: + website: "https://example.com" + login_url: "https://example.com/sign-in" + allow_failure: true + script: + - mkdir /zap/wrk/ + - /zap/zap-baseline.py -J gl-dast-report.json -t $website \ + --auth-url $login_url \ + --auth-username "john.doe@example.com" \ + --auth-password "john-doe-password" || true + - cp /zap/wrk/gl-dast-report.json . + artifacts: + paths: [gl-dast-report.json] +``` +See [zaproxy documentation](https://gitlab.com/gitlab-org/security-products/zaproxy) +to learn more about authentication settings. + TIP: **Tip:** Starting with [GitLab Ultimate][ee] 10.4, this information will be automatically extracted and shown right in the merge request widget. To do diff --git a/doc/ci/examples/deployment/README.md b/doc/ci/examples/deployment/README.md index e80e246c5dd..2dcdc2d41ec 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 ➔ Pipelines ➔ Secret variables**. The variables that are defined +**Settings ➔ CI / CD ➔ 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/pipelines.md b/doc/ci/pipelines.md index 856d7f264e4..301cccc80a3 100644 --- a/doc/ci/pipelines.md +++ b/doc/ci/pipelines.md @@ -2,6 +2,11 @@ > Introduced in GitLab 8.8. +NOTE: **Note:** +If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository), +you may need to enable pipeline triggering in your project's +**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**. + ## Pipelines A pipeline is a group of [jobs][] that get executed in [stages][](batches). @@ -121,9 +126,8 @@ The basic requirements is that there are two numbers separated with one of the following (you can even use them interchangeably): - a space -- a forward slash (`/`) +- a slash (`/`) - a colon (`:`) -- a dot (`.`) >**Note:** More specifically, [it uses][regexp] this regular expression: `\d+[\s:\/\\]+\d+\s*`. diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index f64e868d390..fec0ff87326 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -126,6 +126,11 @@ git push origin master Now if you go to the **Pipelines** page you will see that the pipeline is pending. +NOTE: **Note:** +If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository), +you may need to enable pipeline triggering in your project's +**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**. + You can also go to the **Commits** page and notice the little pause icon next to the commit SHA. diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md index 7a7b50b294d..60dc2ef9ac5 100644 --- a/doc/ci/runners/README.md +++ b/doc/ci/runners/README.md @@ -35,7 +35,7 @@ are: A Runner that is specific only runs for the specified project(s). A shared Runner can run jobs for every project that has enabled the option **Allow shared Runners** -under **Settings ➔ CI/CD**. +under **Settings > CI/CD**. Projects with high demand of CI activity can also benefit from using specific Runners. By having dedicated Runners you are guaranteed that the Runner is not @@ -76,7 +76,7 @@ Registering a specific can be done in two ways: To create a specific Runner without having admin rights to the GitLab instance, visit the project you want to make the Runner work for in GitLab: -1. Go to **Settings ➔ CI/CD** to obtain the token +1. Go to **Settings > CI/CD** to obtain the token 1. [Register the Runner][register] ### Making an existing shared Runner specific @@ -85,7 +85,7 @@ If you are an admin on your GitLab instance, you can turn any shared Runner into a specific one, but not the other way around. Keep in mind that this is a one way transition. -1. Go to the Runners in the admin area **Overview ➔ Runners** (`/admin/runners`) +1. Go to the Runners in the admin area **Overview > Runners** (`/admin/runners`) and find your Runner 1. Enable any projects under **Restrict projects for this Runner** to be used with the Runner @@ -101,7 +101,7 @@ can be changed afterwards under each Runner's settings. To lock/unlock a Runner: -1. Visit your project's **Settings ➔ CI/CD** +1. Visit your project's **Settings > CI/CD** 1. Find the Runner you wish to lock/unlock and make sure it's enabled 1. Click the pencil button 1. Check the **Lock to current projects** option @@ -115,7 +115,7 @@ you can enable the Runner also on any other project where you have Master permis To enable/disable a Runner in your project: -1. Visit your project's **Settings ➔ CI/CD** +1. Visit your project's **Settings > CI/CD** 1. Find the Runner you wish to enable/disable 1. Click **Enable for this project** or **Disable for this project** @@ -124,6 +124,13 @@ Consider that if you don't lock your specific Runner to a specific project, any user with Master role in you project can assign your runner to another arbitrary project without requiring your authorization, so use it with caution. +An admin can enable/disable a specific Runner for projects: + +1. Navigate to **Admin > Runners** +2. Find the Runner you wish to enable/disable +3. Click edit on the Runner +4. Click **Enable** or **Disable** on the project + ## Protected Runners > @@ -136,7 +143,7 @@ Whenever a Runner is protected, the Runner picks only jobs created on To protect/unprotect Runners: -1. Visit your project's **Settings ➔ CI/CD** +1. Visit your project's **Settings > CI/CD** 1. Find a Runner you want to protect/unprotect and make sure it's enabled 1. Click the pencil button besides the Runner name 1. Check the **Protected** option @@ -231,6 +238,38 @@ To make a Runner pick tagged/untagged jobs: 1. Check the **Run untagged jobs** option 1. Click **Save changes** for the changes to take effect +### Setting maximum job timeout for a Runner + +For each Runner you can specify a _maximum job timeout_. Such timeout, +if smaller than [project defined timeout], will take the precedence. This +feature can be used to prevent Shared Runner from being appropriated +by a project by setting a ridiculous big timeout (e.g. one week). + +When not configured, Runner will not override project timeout. + +How this feature will work: + +**Example 1 - Runner timeout bigger than project timeout** + +1. You set the _maximum job timeout_ for a Runner to 24 hours +1. You set the _CI/CD Timeout_ for a project to **2 hours** +1. You start a job +1. The job, if running longer, will be timeouted after **2 hours** + +**Example 2 - Runner timeout not configured** + +1. You remove the _maximum job timeout_ configuration from a Runner +1. You set the _CI/CD Timeout_ for a project to **2 hours** +1. You start a job +1. The job, if running longer, will be timeouted after **2 hours** + +**Example 3 - Runner timeout smaller than project timeout** + +1. You set the _maximum job timeout_ for a Runner to **30 minutes** +1. You set the _CI/CD Timeout_ for a project to 2 hours +1. You start a job +1. The job, if running longer, will be timeouted after **30 minutes** + ### Be careful with sensitive information With some [Runner Executors](https://docs.gitlab.com/runner/executors/README.html), @@ -259,12 +298,6 @@ Mentioned briefly earlier, but the following things of Runners can be exploited. We're always looking for contributions that can mitigate these [Security Considerations](https://docs.gitlab.com/runner/security/). -[install]: http://docs.gitlab.com/runner/install/ -[fifo]: https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics) -[register]: http://docs.gitlab.com/runner/register/ -[protected branches]: ../../user/project/protected_branches.md -[protected tags]: ../../user/project/protected_tags.md - ## Determining the IP address of a Runner > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17286) in GitLab 10.6. @@ -297,3 +330,10 @@ You can find the IP address of a Runner for a specific project by: 1. On the details page you should see a row for "IP Address" ![specific Runner IP address](img/specific_runner_ip_address.png) + +[install]: http://docs.gitlab.com/runner/install/ +[fifo]: https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics) +[register]: http://docs.gitlab.com/runner/register/ +[protected branches]: ../../user/project/protected_branches.md +[protected tags]: ../../user/project/protected_tags.md +[project defined timeout]: ../../user/project/pipelines/settings.html#timeout diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index bd4aeb006bd..9f268f47e6f 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -449,6 +449,72 @@ export CI_REGISTRY_USER="gitlab-ci-token" export CI_REGISTRY_PASSWORD="longalfanumstring" ``` +## Variables expressions + +> Variables expressions were added in GitLab 10.7. + +It is possible to use variables expressions with only / except policies in +`.gitlab-ci.yml`. By using this approach you can limit what builds are going to +be created within a pipeline after pushing code to GitLab. + +This is particularly useful in combination with secret variables and triggered +pipeline variables. + +```yaml +deploy: + script: cap staging deploy + environment: staging + only: + variables: + - $RELEASE == "staging" + - $STAGING +``` + +Each provided variables expression is going to be evaluated before creating +a pipeline. + +If any of the conditions in `variables` evaluates to truth when using `only`, +a new job is going to be created. If any of the expressions evaluates to truth +when `except` is being used, a job is not going to be created. + +This follows usual rules for `only` / `except` policies. + +### Supported syntax + +Below you can find currently supported syntax reference: + +1. Equality matching using a string + + Example: `$VARIABLE == "some value"` + + You can use equality operator `==` to compare a variable content to a + string. We support both, double quotes and single quotes to define a string + value, so both `$VARIABLE == "some value"` and `$VARIABLE == 'some value'` + are supported. `"some value" == $VARIABLE` is correct too. + +1. Checking for an undefined value + + It sometimes happens that you want to check whether variable is defined or + not. To do that, you can compare variable to `null` value, like + `$VARIABLE == null`. This expression is going to evaluate to truth if + variable is not set. + +1. Checking for an empty variable + + If you want to check whether a variable is defined, but is empty, you can + simply compare it against an empty string, like `$VAR == ''`. + +1. Comparing two variables + + It is possible to compare two variables. `$VARIABLE_1 == $VARIABLE_2`. + +1. Variable presence check + + If you only want to create a job when there is some variable present, + which means that it is defined and non-empty, you can simply use + variable name as an expression, like `$STAGING`. If `$STAGING` variable + is defined, and is non empty, expression will evaluate to truth. + [ce-13784]: https://gitlab.com/gitlab-org/gitlab-ce/issues/13784 "Simple protection of CI secret variables" [eep]: https://about.gitlab.com/products/ "Available only in GitLab Premium" [envs]: ../environments.md diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index c2b06e53c2f..be114e7008e 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -10,6 +10,11 @@ of your repository and contains definitions of how your project should be built. If you want a quick introduction to GitLab CI, follow our [quick start guide](../quick_start/README.md). +NOTE: **Note:** +If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository), +you may need to enable pipeline triggering in your project's +**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**. + ## Jobs The YAML file defines a set of jobs with constraints stating when they should @@ -315,9 +320,14 @@ policy configuration. GitLab now supports both, simple and complex strategies, so it is possible to use an array and a hash configuration scheme. -Two keys are now available: `refs` and `kubernetes`. Refs strategy equals to -simplified only/except configuration, whereas kubernetes strategy accepts only -`active` keyword. +Three keys are now available: `refs`, `kubernetes` and `variables`. +Refs strategy equals to simplified only/except configuration, whereas +kubernetes strategy accepts only `active` keyword. + +`variables` keyword is used to define variables expressions. In other words +you can use predefined variables / secret variables / project / group or +environment-scoped variables to define an expression GitLab is going to +evaluate in order to decide whether a job should be created or not. See the example below. Job is going to be created only when pipeline has been scheduled or runs for a `master` branch, and only if kubernetes service is @@ -332,6 +342,20 @@ job: kubernetes: active ``` +Example of using variables expressions: + +```yaml +deploy: + only: + refs: + - branches + variables: + - $RELEASE == "staging" + - $STAGING +``` + +Learn more about variables expressions on a separate page. + ## `tags` `tags` is used to select specific Runners from the list of all Runners that are diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index fea92e740cb..287143d6255 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -33,6 +33,26 @@ rest of the code should be as close to the CE files as possible. [single code base]: https://gitlab.com/gitlab-org/gitlab-ee/issues/2952#note_41016454 +### EE-specific comments + +When complete separation can't be achieved with the `ee/` directory, you can wrap +code in EE specific comments to designate the difference from CE/EE and add +some context for someone resolving a conflict. + +```rb +# EE-specific start +stub_licensed_features(variable_environment_scope: true) +# EE specific end +``` + +```haml +-# EE-specific start += render 'ci/variables/environment_scope', form_field: form_field, variable: variable +-# EE-specific end +``` + +EE-specific comments should not be backported to CE. + ### Detection of EE-only files For each commit (except on `master`), the `ee-files-location-check` CI job tries @@ -350,6 +370,255 @@ class beneath the `EE` module just as you would normally. For example, if CE has LDAP classes in `lib/gitlab/ldap/` then you would place EE-specific LDAP classes in `ee/lib/ee/gitlab/ldap`. +### Code in `lib/api/` + +It can be very tricky to extend EE features by a single line of `prepend`, +and for each different [Grape](https://github.com/ruby-grape/grape) feature, +we might need different strategies to extend it. To apply different strategies +easily, we would use `extend ActiveSupport::Concern` in the EE module. + +Put the EE module files following +[EE features based on CE features](#ee-features-based-on-ce-features). + +#### EE API routes + +For EE API routes, we put them in a `prepended` block: + +``` ruby +module EE + module API + module MergeRequests + extend ActiveSupport::Concern + + prepended do + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: ::API::API::PROJECT_ENDPOINT_REQUIREMENTS do + # ... + end + end + end + end +end +``` + +Note that due to namespace differences, we need to use the full qualifier for some +constants. + +#### EE params + +We can define `params` and utilize `use` in another `params` definition to +include params defined in EE. However, we need to define the "interface" first +in CE in order for EE to override it. We don't have to do this in other places +due to `prepend`, but Grape is complex internally and we couldn't easily do +that, so we'll follow regular object-oriented practices that we define the +interface first here. + +For example, suppose we have a few more optional params for EE, given this CE +API code: + +``` ruby +module API + class MergeRequests < Grape::API + # EE::API::MergeRequests would override the following helpers + helpers do + params :optional_params_ee do + end + end + + prepend EE::API::MergeRequests + + params :optional_params do + # CE specific params go here... + + use :optional_params_ee + end + end +end +``` + +And then we could override it in EE module: + +``` ruby +module EE + module API + module MergeRequests + extend ActiveSupport::Concern + + prepended do + helpers do + params :optional_params_ee do + # EE specific params go here... + end + end + end + end + end +end +``` + +This way, the only difference between CE and EE for that API file would be +`prepend EE::API::MergeRequests`. + +#### EE helpers + +To make it easy for an EE module to override the CE helpers, we need to define +those helpers we want to extend first. Try to do that immediately after the +class definition to make it easy and clear: + +``` ruby +module API + class JobArtifacts < Grape::API + # EE::API::JobArtifacts would override the following helpers + helpers do + def authorize_download_artifacts! + authorize_read_builds! + end + end + + prepend EE::API::JobArtifacts + end +end +``` + +And then we can follow regular object-oriented practices to override it: + +``` ruby +module EE + module API + module JobArtifacts + extend ActiveSupport::Concern + + prepended do + helpers do + def authorize_download_artifacts! + super + check_cross_project_pipelines_feature! + end + end + end + end + end +end +``` + +#### EE-specific behaviour + +Sometimes we need EE-specific behaviour in some of the APIs. Normally we could +use EE methods to override CE methods, however API routes are not methods and +therefore can't be simply overridden. We need to extract them into a standalone +method, or introduce some "hooks" where we could inject behavior in the CE +route. Something like this: + +``` ruby +module API + class MergeRequests < Grape::API + helpers do + # EE::API::MergeRequests would override the following helpers + def update_merge_request_ee(merge_request) + end + end + + prepend EE::API::MergeRequests + + put ':id/merge_requests/:merge_request_iid/merge' do + merge_request = find_project_merge_request(params[:merge_request_iid]) + + # ... + + update_merge_request_ee(merge_request) + + # ... + end + end +end +``` + +Note that `update_merge_request_ee` doesn't do anything in CE, but +then we could override it in EE: + +``` ruby +module EE + module API + module MergeRequests + extend ActiveSupport::Concern + + prepended do + helpers do + def update_merge_request_ee(merge_request) + # ... + end + end + end + end + end +end +``` + +#### EE `route_setting` + +It's very hard to extend this in an EE module, and this is simply storing +some meta-data for a particular route. Given that, we could simply leave the +EE `route_setting` in CE as it won't hurt and we are just not going to use +those meta-data in CE. + +We could revisit this policy when we're using `route_setting` more and whether +or not we really need to extend it from EE. For now we're not using it much. + +#### Utilizing class methods for setting up EE-specific data + +Sometimes we need to use different arguments for a particular API route, and we +can't easily extend it with an EE module because Grape has different context in +different blocks. In order to overcome this, we could use class methods from the +API class. + +For example, in one place we need to pass an extra argument to +`at_least_one_of` so that the API could consider an EE-only argument as the +least argument. This is not quite beautiful but it's working: + +``` ruby +module API + class MergeRequests < Grape::API + def self.update_params_at_least_one_of + %i[ + assignee_id + description + ] + end + + prepend EE::API::MergeRequests + + params do + at_least_one_of(*::API::MergeRequests.update_params_at_least_one_of) + end + end +end +``` + +And then we could easily extend that argument in the EE class method: + +``` ruby +module EE + module API + module MergeRequests + extend ActiveSupport::Concern + + class_methods do + def update_params_at_least_one_of + super.push(*%i[ + squash + ]) + end + end + end + end +end +``` + +It could be annoying if we need this for a lot of routes, but it might be the +simplest solution right now. + ### Code in `spec/` When you're testing EE-only features, avoid adding examples to the @@ -375,6 +644,7 @@ information on managing page-specific javascript within EE. To separate EE-specific styles in SCSS files, if a component you're adding styles for is limited to only EE, it is better to have a separate SCSS file in appropriate directory within `app/assets/stylesheets`. +See [backporting changes](#backporting-changes) for instructions on how to merge changes safely. In some cases, this is not entirely possible or creating dedicated SCSS file is an overkill, e.g. a text style of some component is different for EE. In such cases, @@ -405,14 +675,28 @@ to avoid conflicts during CE to EE merge. } } -/* EE-specific styles */ +// EE-specific start .section-body.ee-section-body { .section-title { background: $gl-header-color-cyan; } } +// EE-specific end ``` +### Backporting changes from EE to CE + +When working in EE-specific features, you might have to tweak a few files that are not EE-specific. Here is a workflow to make sure those changes end up backported safely into CE too. +(This approach does not refer to changes introduced via [csslab](https://gitlab.com/gitlab-org/csslab/).) + +1. **Make your changes in the EE branch.** If possible, keep a separated commit (to be squashed) to help backporting and review. +1. **Open merge request to EE project.** +1. **Apply the changes you made to CE files in a branch of the CE project.** (Tip: Use `patch` with the diff from your commit in EE branch) +1. **Open merge request to CE project**, referring it's a backport of EE changes and link to MR open in EE. +1. Once EE MR is merged, the MR towards CE can be merged. **But not before**. + +**Note:** regarding SCSS, make sure the files living outside `/ee/` don't diverge between CE and EE projects. + ## gitlab-svgs Conflicts in `app/assets/images/icons.json` or `app/assets/images/icons.svg` can diff --git a/doc/development/new_fe_guide/style/javascript.md b/doc/development/new_fe_guide/style/javascript.md index 480d50a211f..57efd9353bc 100644 --- a/doc/development/new_fe_guide/style/javascript.md +++ b/doc/development/new_fe_guide/style/javascript.md @@ -1,3 +1,195 @@ # JavaScript style guide -> TODO: Add content +We use [Airbnb's JavaScript Style Guide][airbnb-style-guide] and it's accompanying linter to manage most of our JavaScript style guidelines. + +In addition to the style guidelines set by Airbnb, we also have a few specific rules listed below. + +> **Tip:** +You can run eslint locally by running `yarn eslint` + +## Arrays + +<a name="avoid-foreach"></a><a name="1.1"></a> +- [1.1](#avoid-foreach) **Avoid ForEach when mutating data** Use `map`, `reduce` or `filter` instead of `forEach` when mutating data. This will minimize mutations in functions ([which is aligned with Airbnb's style guide][airbnb-minimize-mutations]) + +``` +// bad +users.forEach((user, index) => { + user.id = index; +}); + +// good +const usersWithId = users.map((user, index) => { + return Object.assign({}, user, { id: index }); +}); +``` + +## Functions + +<a name="limit-params"></a><a name="2.1"></a> +- [2.1](#limit-params) **Limit number of parameters** If your function or method has more than 3 parameters, use an object as a parameter instead. + +``` +// bad +function a(p1, p2, p3) { + // ... +}; + +// good +function a(p) { + // ... +}; +``` + +## Classes & constructors + +<a name="avoid-constructor-side-effects"></a><a name="3.1"></a> +- [3.1](#avoid-constructor-side-effects) **Avoid side effects in constructors** Avoid making some operations in the `constructor`, such as asynchronous calls, API requests and DOM manipulations. Prefer moving them into separate functions. This will make tests easier to write and code easier to maintain. + + ```javascript + // bad + class myClass { + constructor(config) { + this.config = config; + axios.get(this.config.endpoint) + } + } + + // good + class myClass { + constructor(config) { + this.config = config; + } + + makeRequest() { + axios.get(this.config.endpoint) + } + } + const instance = new myClass(); + instance.makeRequest(); + + ``` + +<a name="avoid-classes-to-handle-dom-events"></a><a name="3.2"></a> +- [3.2](#avoid-classes-to-handle-dom-events) **Avoid classes to handle DOM events** If the only purpose of the class is to bind a DOM event and handle the callback, prefer using a function. + +``` +// bad +class myClass { + constructor(config) { + this.config = config; + } + + init() { + document.addEventListener('click', () => {}); + } +} + +// good + +const myFunction = () => { + document.addEventListener('click', () => { + // handle callback here + }); +} +``` + +<a name="element-container"></a><a name="3.3"></a> +- [3.3](#element-container) **Pass element container to constructor** When your class manipulates the DOM, receive the element container as a parameter. +This is more maintainable and performant. + +``` +// bad +class a { + constructor() { + document.querySelector('.b'); + } +} + +// good +class a { + constructor(options) { + options.container.querySelector('.b'); + } +} +``` + +## Type Casting & Coercion + +<a name="use-parseint"></a><a name="4.1"></a> +- [4.1](#use-parseint) **Use ParseInt** Use `ParseInt` when converting a numeric string into a number. + +``` +// bad +Number('10') + + +// good +parseInt('10', 10); +``` + +## CSS Selectors + +<a name="use-js-prefix"></a><a name="5.1"></a> +- [5.1](#use-js-prefix) **Use js prefix** If a CSS class is only being used in JavaScript as a reference to the element, prefix the class name with `js-` + +``` +// bad +<button class="add-user"></button> + +// good +<button class="js-add-user"></button> +``` + +## Modules + +<a name="use-absolute-paths"></a><a name="6.1"></a> +- [6.1](#use-absolute-paths) **Use absolute paths for nearby modules** Use absolute paths if the module you are importing is less than two levels up. + +``` +// bad +import GitLabStyleGuide from '~/guides/GitLabStyleGuide'; + +// good +import GitLabStyleGuide from '../GitLabStyleGuide'; +``` + +<a name="use-relative-paths"></a><a name="6.2"></a> +- [6.2](#use-relative-paths) **Use relative paths for distant modules** If the module you are importing is two or more levels up, use a relative path instead of an absolute path. + +``` +// bad +import GitLabStyleGuide from '../../../guides/GitLabStyleGuide'; + +// good +import GitLabStyleGuide from '~/GitLabStyleGuide'; +``` + +<a name="global-namespace"></a><a name="6.3"></a> +- [6.3](#global-namespace) **Do not add to global namespace** + +<a name="domcontentloaded"></a><a name="6.4"></a> +- [6.4](#domcontentloaded) **Do not use DOMContentLoaded in non-page modules** Imported modules should act the same each time they are loaded. `DOMContentLoaded` events are only allowed on modules loaded in the `/pages/*` directory because those are loaded dynamically with webpack. + +## Security + +<a name="avoid-xss"></a><a name="7.1"></a> +- [7.1](#avoid-xss) **Avoid XSS** Do not use `innerHTML`, `append()` or `html()` to set content. It opens up too many vulnerabilities. + +## ESLint + +<a name="disable-eslint-file"></a><a name="8.1"></a> +- [8.1](#disable-eslint-file) **Disabling ESLint in new files** Do not disable ESLint when creating new files. Existing files may have existing rules disabled due to legacy compatibility reasons but they are in the process of being refactored. + +<a name="disable-eslint-rule"></a><a name="8.2"></a> +- [8.2](#disable-eslint-rule) **Disabling ESLint rule** Do not disable specific ESLint rules. Due to technical debt, you may disable the following rules only if you are invoking/instantiating existing code modules + + - [no-new][no-new] + - [class-method-use-this][class-method-use-this] + +> Note: Disable these rules on a per line basis. This makes it easier to refactor in the future. E.g. use `eslint-disable-next-line` or `eslint-disable-line` + +[airbnb-style-guide]: https://github.com/airbnb/javascript +[airbnb-minimize-mutations]: https://github.com/airbnb/javascript#testing--for-real +[no-new]: http://eslint.org/docs/rules/no-new +[class-method-use-this]: http://eslint.org/docs/rules/class-methods-use-this diff --git a/doc/install/README.md b/doc/install/README.md index 87f6969b415..9724b56910d 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -36,7 +36,7 @@ the full process of installing GitLab on Google Container Engine (GKE), pushing - [Install on AWS](https://about.gitlab.com/aws/) - _Testing only!_ [DigitalOcean and Docker Machine](digitaloceandocker.md) - Quickly test any version of GitLab on DigitalOcean using Docker Machine. -- [Getting started with GitLab and DigitalOcean](ttps://about.gitlab.com/2016/04/27/getting-started-with-gitlab-and-digitalocean/): requirements, installation process, updates. +- [Getting started with GitLab and DigitalOcean](https://about.gitlab.com/2016/04/27/getting-started-with-gitlab-and-digitalocean/): requirements, installation process, updates. - [Demo: Cloud Native Development with GitLab](https://about.gitlab.com/2017/04/18/cloud-native-demo/): video demonstration on how to install GitLab on Kubernetes, build a project, create Review Apps, store Docker images in Container Registry, deploy to production on Kubernetes, and monitor with Prometheus. ## Database diff --git a/doc/integration/google.md b/doc/integration/google.md index 07a700f7b64..ae1d848f439 100644 --- a/doc/integration/google.md +++ b/doc/integration/google.md @@ -35,7 +35,7 @@ In Google's side: 1. You should now be able to see a Client ID and Client secret. Note them down or keep this page open as you will need them later. -1. From the **Dashboard** select **ENABLE APIS AND SERVICES > Compute > Google Container Engine API > Enable** +1. From the **Dashboard** select **ENABLE APIS AND SERVICES > Compute > Google Kubernetes Engine API > Enable** On your GitLab server: diff --git a/doc/policy/maintenance.md b/doc/policy/maintenance.md index 8d0afa9e692..7f028565412 100644 --- a/doc/policy/maintenance.md +++ b/doc/policy/maintenance.md @@ -44,7 +44,7 @@ This decision is made on a case-by-case basis. ## Upgrade recommendations -We encourage everyone to run the latest stable release to ensure that you can +We encourage everyone to run the [latest stable release](https://about.gitlab.com/blog/categories/release/) to ensure that you can easily upgrade to the most secure and feature-rich GitLab experience. In order to make sure you can easily run the most recent stable release, we are working hard to keep the update process simple and reliable. diff --git a/doc/update/10.5-to-10.6.md b/doc/update/10.5-to-10.6.md index f5c5c305726..2f90fb62c4a 100644 --- a/doc/update/10.5-to-10.6.md +++ b/doc/update/10.5-to-10.6.md @@ -103,7 +103,7 @@ rm go1.8.3.linux-amd64.tar.gz ```bash cd /home/git/gitlab -sudo -u git -H git fetch --all +sudo -u git -H git fetch --all --prune sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically sudo -u git -H git checkout -- locale ``` @@ -131,7 +131,7 @@ sudo -u git -H git checkout 10-6-stable-ee ```bash cd /home/git/gitlab-shell -sudo -u git -H git fetch --all --tags +sudo -u git -H git fetch --all --tags --prune sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION) sudo -u git -H bin/compile ``` @@ -146,7 +146,7 @@ If you are not using Linux you may have to run `gmake` instead of ```bash cd /home/git/gitlab-workhorse -sudo -u git -H git fetch --all --tags +sudo -u git -H git fetch --all --tags --prune sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION) sudo -u git -H make ``` @@ -182,7 +182,7 @@ sudo -u git -H sed -i.pre-10.1 's/\[\[storages\]\]/[[storage]]/' /home/git/gital ```shell cd /home/git/gitaly -sudo -u git -H git fetch --all --tags +sudo -u git -H git fetch --all --tags --prune sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION) sudo -u git -H make ``` diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md index 83eb7a225b2..d5f77191938 100644 --- a/doc/user/gitlab_com/index.md +++ b/doc/user/gitlab_com/index.md @@ -51,7 +51,7 @@ Below are the settings for [GitLab Pages]. | TLS certificates support| yes | no | The maximum size of your Pages site is regulated by the artifacts maximum size -which is part of [GitLab CI](#gitlab-ci). +which is part of [GitLab CI/CD](#gitlab-ci-cd). ## GitLab CI/CD @@ -61,6 +61,14 @@ Below are the current settings regarding [GitLab CI/CD](../../ci/README.md). | ----------- | ----------------- | ------------- | | Artifacts maximum size | 1G | 100M | +## Repository size limit + +The maximum size your Git repository is allowed to be including LFS. + +| Setting | GitLab.com | Default | +| ----------- | ----------------- | ------------- | +| Repository size including LFS | 10G | Unlimited | + ## Shared Runners Shared Runners on GitLab.com run in [autoscale mode] and powered by diff --git a/doc/user/project/integrations/img/jira_workflow_screenshot.png b/doc/user/project/integrations/img/jira_workflow_screenshot.png Binary files differdeleted file mode 100644 index e62fb202613..00000000000 --- a/doc/user/project/integrations/img/jira_workflow_screenshot.png +++ /dev/null diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md index fc527663db0..5933bcedc8b 100644 --- a/doc/user/project/integrations/jira.md +++ b/doc/user/project/integrations/jira.md @@ -113,7 +113,20 @@ in the table below. | `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`. | | `Username` | The user name created in [configuring JIRA step](#configuring-jira). | | `Password` |The password of the user created in [configuring JIRA step](#configuring-jira). | -| `Transition ID` | This is the ID of a transition that moves issues to a closed state. You can find this number under JIRA workflow administration ([see screenshot](img/jira_workflow_screenshot.png)). **Closing JIRA issues via commits or Merge Requests won't work if you don't set the ID correctly.** | +| `Transition ID` | This is the ID of a transition that moves issues to the desired state. **Closing JIRA issues via commits or Merge Requests won't work if you don't set the ID correctly.** | + +### Getting a transition ID + +In the most recent JIRA UI, you can no longer see transition IDs in the workflow +administration UI. You can get the ID you need in either of the following ways: + +1. By using the API, with a request like `https://yourcompany.atlassian.net/rest/api/2/issue/ISSUE-123/transitions` + using an issue that is in the appropriate "open" state +1. By mousing over the link for the transition you want and looking for the + "action" parameter in the URL + +Note that the transition ID may vary between workflows (e.g., bug vs. story), +even if the status you are changing to is the same. After saving the configuration, your GitLab project will be able to interact with all JIRA projects in your JIRA instance and you'll see the JIRA link on the GitLab project pages that takes you to the appropriate JIRA project. diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md index d403d5698a9..b4a842f33d6 100644 --- a/doc/user/project/issue_board.md +++ b/doc/user/project/issue_board.md @@ -251,7 +251,7 @@ Different issue board features are available in different [GitLab tiers](https:/ | Tier | Number of project issue boards | Board with configuration in project issue boards | Number of group issue boards | Board with configuration in group issue boards | | --- | --- | --- | --- | --- | -| Libre | 1 | No | 1 | No | +| Core | 1 | No | 1 | No | | Starter | Multiple | Yes | 1 | No | | Premium | Multiple | Yes | Multiple | Yes | | Ultimate | Multiple | Yes | Multiple | Yes | diff --git a/doc/user/project/pipelines/settings.md b/doc/user/project/pipelines/settings.md index 43451844f2d..6cead7b9961 100644 --- a/doc/user/project/pipelines/settings.md +++ b/doc/user/project/pipelines/settings.md @@ -27,6 +27,13 @@ The default value is 60 minutes. Decrease the time limit if you want to impose a hard limit on your jobs' running time or increase it otherwise. In any case, if the job surpasses the threshold, it is marked as failed. +### Timeout overriding on Runner level + +> - [Introduced][ce-17221] in GitLab 10.7. + +Project defined timeout (either specific timeout set by user or the default +60 minutes timeout) may be [overridden on Runner level][timeout overriding]. + ## Custom CI config path > - [Introduced][ce-12509] in GitLab 9.4. @@ -152,5 +159,7 @@ into your `README.md`: [var]: ../../../ci/yaml/README.md#git-strategy [coverage report]: #test-coverage-parsing +[timeout overriding]: ../../../ci/runners/README.html#setting-maximum-job-timeout-for-a-runner [ce-9362]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9362 [ce-12509]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12509 +[ce-17221]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17221 diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md index ae131d51305..376f4e3cbe4 100644 --- a/doc/user/project/repository/index.md +++ b/doc/user/project/repository/index.md @@ -132,8 +132,9 @@ Use GPG to [sign your commits](gpg_signed_commits/index.md). ## Repository size -In GitLab.com, your repository size limit it 10GB. For other instances, -the repository size is limited by your system administrators. +On GitLab.com, your [repository size limit is 10GB](../../gitlab_com/index.md#repository-size-limit) +(including LFS). For other instances, the repository size is limited by your +system administrators. You can also [reduce a repository size using Git](reducing_the_repo_size_using_git.md). diff --git a/doc/workflow/lfs/lfs_administration.md b/doc/workflow/lfs/lfs_administration.md index cac3cb599dd..f824756c10c 100644 --- a/doc/workflow/lfs/lfs_administration.md +++ b/doc/workflow/lfs/lfs_administration.md @@ -19,7 +19,7 @@ There are various configuration options to help GitLab server administrators: * Changing the location of LFS object storage * Setting up AWS S3 compatible object storage -### Omnibus packages +### Configuration for Omnibus installations In `/etc/gitlab/gitlab.rb`: @@ -33,7 +33,7 @@ gitlab_rails['lfs_enabled'] = false gitlab_rails['lfs_storage_path'] = "/mnt/storage/lfs-objects" ``` -### Installations from source +### Configuration for installations from source In `config/gitlab.yml`: @@ -44,20 +44,21 @@ In `config/gitlab.yml`: storage_path: /mnt/storage/lfs-objects ``` -## Setting up S3 compatible object storage +## Storing the LFS objects in an S3-compatible object storage -> **Note:** [Introduced][ee-2760] in [GitLab Premium][eep] 10.0. -> Available in [GitLab CE][ce] 10.7 +> [Introduced][ee-2760] in [GitLab Premium][eep] 10.0. Brought to GitLab Core +in 10.7. -It is possible to store LFS objects on remote object storage instead of on a local disk. +It is possible to store LFS objects on a remote object storage which allows you +to offload storage to an external AWS S3 compatible service, freeing up disk +space locally. You can also host your own S3 compatible storage decoupled from +GitLab, with with a service such as [Minio](https://www.minio.io/). -This allows you to offload storage to an external AWS S3 compatible service, freeing up disk space locally. You can also host your own S3 compatible storage decoupled from GitLab, with with a service such as [Minio](https://www.minio.io/). +Object storage currently transfers files first to GitLab, and then on the +object storage in a second stage. This can be done either by using a rake task +to transfer existing objects, or in a background job after each file is received. -Object storage currently transfers files first to GitLab, and then on the object storage in a second stage. This can be done either by using a rake task to transfer existing objects, or in a background job after each file is received. - -### Object Storage Settings - -For source installations the following settings are nested under `lfs:` and then `object_store:`. On omnibus installs they are prefixed by `lfs_object_store_`. +The following general settings are supported. | Setting | Description | Default | |---------|-------------|---------| @@ -68,9 +69,7 @@ For source installations the following settings are nested under `lfs:` and then | `proxy_download` | Set to true to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data | `false` | | `connection` | Various connection options described below | | -#### S3 compatible connection settings - -The connection settings match those provided by [Fog](https://github.com/fog), and are as follows: +The `connection` settings match those provided by [Fog](https://github.com/fog). | Setting | Description | Default | |---------|-------------|---------| @@ -82,8 +81,43 @@ The connection settings match those provided by [Fog](https://github.com/fog), a | `endpoint` | Can be used when configuring an S3 compatible service such as [Minio](https://www.minio.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) | | `path_style` | Set to true to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Leave as false for AWS S3 | false | +### S3 for Omnibus installations + +On Omnibus installations, the settings are prefixed by `lfs_object_store_`: + +1. Edit `/etc/gitlab/gitlab.rb` and add the following lines by replacing with + the values you want: + + ```ruby + gitlab_rails['lfs_object_store_enabled'] = true + gitlab_rails['lfs_object_store_remote_directory'] = "lfs-objects" + gitlab_rails['lfs_object_store_connection'] = { + 'provider' => 'AWS', + 'region' => 'eu-central-1', + 'aws_access_key_id' => '1ABCD2EFGHI34JKLM567N', + 'aws_secret_access_key' => 'abcdefhijklmnopQRSTUVwxyz0123456789ABCDE', + # The below options configure an S3 compatible host instead of AWS + 'host' => 'localhost', + 'endpoint' => 'http://127.0.0.1:9000', + 'path_style' => true + } + ``` + +1. Save the file and [reconfigure GitLab]s for the changes to take effect. +1. Migrate any existing local LFS objects to the object storage: + + ```bash + gitlab-rake gitlab:lfs:migrate + ``` + + This will migrate existing LFS objects to object storage. New LFS objects + will be forwarded to object storage unless + `gitlab_rails['lfs_object_store_background_upload']` is set to false. -### From source +### S3 for installations from source + +For source installations the settings are nested under `lfs:` and then +`object_store:`: 1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines: @@ -108,44 +142,13 @@ The connection settings match those provided by [Fog](https://github.com/fog), a 1. Save the file and [restart GitLab][] for the changes to take effect. 1. Migrate any existing local LFS objects to the object storage: - ```bash - sudo -u git -H bundle exec rake gitlab:lfs:migrate RAILS_ENV=production - ``` - - This will migrate existing LFS objects to object storage. New LFS objects - will be forwarded to object storage unless - `gitlab_rails['lfs_object_store_background_upload']` is set to false. - -### In Omnibus - -1. Edit `/etc/gitlab/gitlab.rb` and add the following lines by replacing with - the values you want: - - ```ruby - gitlab_rails['lfs_object_store_enabled'] = true - gitlab_rails['lfs_object_store_remote_directory'] = "lfs-objects" - gitlab_rails['lfs_object_store_connection'] = { - 'provider' => 'AWS', - 'region' => 'eu-central-1', - 'aws_access_key_id' => '1ABCD2EFGHI34JKLM567N', - 'aws_secret_access_key' => 'abcdefhijklmnopQRSTUVwxyz0123456789ABCDE', - # The below options configure an S3 compatible host instead of AWS - 'host' => 'localhost', - 'endpoint' => 'http://127.0.0.1:9000', - 'path_style' => true - } - ``` - -1. Save the file and [reconfigure GitLab]s for the changes to take effect. -1. Migrate any existing local LFS objects to the object storage: - - ```bash - gitlab-rake gitlab:lfs:migrate - ``` + ```bash + sudo -u git -H bundle exec rake gitlab:lfs:migrate RAILS_ENV=production + ``` - This will migrate existing LFS objects to object storage. New LFS objects - will be forwarded to object storage unless - `gitlab_rails['lfs_object_store_background_upload']` is set to false. + This will migrate existing LFS objects to object storage. New LFS objects + will be forwarded to object storage unless `background_upload` is set to + false. ## Storage statistics diff --git a/doc/workflow/todos.md b/doc/workflow/todos.md index 3d8d3ce8f13..e612646cfbc 100644 --- a/doc/workflow/todos.md +++ b/doc/workflow/todos.md @@ -28,11 +28,10 @@ A Todo appears in your Todos dashboard when: - an issue or merge request is assigned to you, - you are `@mentioned` in an issue or merge request, be it the description of the issue/merge request or in a comment, +- you are `@mentioned` in a comment on a commit, - a job in the CI pipeline running for your merge request failed, but this job is not allowed to fail. ->**Note:** Commenting on a commit will _not_ trigger a Todo. - ### Directly addressed Todos > [Introduced][ce-7926] in GitLab 9.0. diff --git a/features/groups.feature b/features/groups.feature deleted file mode 100644 index 4044bd9be79..00000000000 --- a/features/groups.feature +++ /dev/null @@ -1,73 +0,0 @@ -Feature: Groups - Background: - Given I sign in as "John Doe" - And "John Doe" is owner of group "Owned" - - Scenario: I should not see a group if it does not exist - When I visit group "NonExistentGroup" page - Then page status code should be 404 - - @javascript - Scenario: I should see group "Owned" dashboard list - When I visit group "Owned" page - Then I should see group "Owned" projects list - - @javascript - Scenario: I should see group "Owned" activity feed - When I visit group "Owned" activity page - And I should see projects activity feed - - Scenario: I should see group "Owned" issues list - Given project from group "Owned" has issues assigned to me - When I visit group "Owned" issues page - Then I should see issues from group "Owned" assigned to me - - Scenario: I should not see issues from archived project in "Owned" group issues list - Given Group "Owned" has archived project - And the archived project have some issues - When I visit group "Owned" issues page - Then I should not see issues from the archived project - - Scenario: I should see group "Owned" merge requests list - Given project from group "Owned" has merge requests assigned to me - When I visit group "Owned" merge requests page - Then I should see merge requests from group "Owned" assigned to me - - Scenario: I should not see merge requests from archived project in "Owned" group merge requests list - Given Group "Owned" has archived project - And the archived project have some merge_requests - When I visit group "Owned" merge requests page - Then I should not see merge requests from the archived project - - Scenario: I edit group "Owned" avatar - When I visit group "Owned" settings page - And I change group "Owned" avatar - And I visit group "Owned" settings page - Then I should see new group "Owned" avatar - And I should see the "Remove avatar" button - - Scenario: I remove group "Owned" avatar - When I visit group "Owned" settings page - And I have group "Owned" avatar - And I visit group "Owned" settings page - And I remove group "Owned" avatar - Then I should not see group "Owned" avatar - And I should not see the "Remove avatar" button - - # Group projects in settings - Scenario: I should see all projects in the project list in settings - Given Group "Owned" has archived project - When I visit group "Owned" projects page - Then I should see group "Owned" projects list - And I should see "archived" label - - # Public group - @javascript - Scenario: Signed out user should see group - Given "Mary Jane" is owner of group "Owned" - And I am a signed out user - And Group "Owned" has a public project "Public-project" - When I visit group "Owned" page - Then I should see group "Owned" - Then I should see project "Public-project" - diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature deleted file mode 100644 index 819354bb780..00000000000 --- a/features/project/issues/issues.feature +++ /dev/null @@ -1,180 +0,0 @@ -@project_issues -Feature: Project Issues - Background: - Given I sign in as a user - And I own project "Shop" - And project "Shop" have "Release 0.4" open issue - And project "Shop" have "Tweet control" open issue - And project "Shop" have "Release 0.3" closed issue - And I visit project "Shop" issues page - - Scenario: I should see open 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 - And I should see "Release 0.4" in issues - - Scenario: I visit issue page - Given I click link "Release 0.4" - Then I should see issue "Release 0.4" - - Scenario: I submit new unassigned issue - Given I click link "New Issue" - And I submit new issue "500 error on profile" - Then I should see issue "500 error on profile" - - @javascript - Scenario: I submit new unassigned issue with labels - Given project "Shop" has labels: "bug", "feature", "enhancement" - And I click link "New Issue" - And I submit new issue "500 error on profile" with label 'bug' - Then I should see issue "500 error on profile" - And I should see label 'bug' with issue - - @javascript - Scenario: I comment issue - Given I visit issue page "Release 0.4" - And I leave a comment like "XML attached" - Then I should see comment "XML attached" - And I should see an error alert section within the comment form - - @javascript - Scenario: Visiting Issues after being sorted the list - Given I visit project "Shop" issues page - And I sort the list by "Last updated" - And I visit my project's home page - And I visit project "Shop" issues page - Then The list should be sorted by "Last updated" - - @javascript - Scenario: Visiting Merge Requests after being sorted the list - Given project "Shop" has a "Bugfix MR" merge request open - And I visit project "Shop" issues page - And I sort the list by "Last updated" - And I visit project "Shop" merge requests page - Then The list should be sorted by "Last updated" - - @javascript - Scenario: Visiting Merge Requests from a differente Project after sorting - Given project "Shop" has a "Bugfix MR" merge request open - And I visit project "Shop" merge requests page - And I sort the list by "Last updated" - And I visit dashboard merge requests page - Then The list should be sorted by "Last updated" - - @javascript - Scenario: Sort issues by upvotes/downvotes - Given project "Shop" have "Bugfix" open issue - And issue "Release 0.4" have 2 upvotes and 1 downvote - And issue "Tweet control" have 1 upvote and 2 downvotes - And I sort the list by "Popularity" - Then The list should be sorted by "Popularity" - - # Markdown - - @javascript - Scenario: Headers inside the description should have ids generated for them. - Given I visit issue page "Release 0.4" - Then Header "Description header" should have correct id and link - - @javascript - Scenario: Headers inside comments should not have ids generated for them. - Given I visit issue page "Release 0.4" - And I leave a comment with a header containing "Comment with a header" - Then The comment with the header should not have an ID - - @javascript - Scenario: Blocks inside comments should not build relative links - Given I visit issue page "Release 0.4" - And I leave a comment with code block - Then The code block should be unchanged - - Scenario: Issues on empty project - Given empty project "Empty Project" - And I have an ssh key - When I visit empty project page - And I see empty project details with ssh clone info - When I visit empty project's issues page - Given I click link "New Issue" - And I submit new issue "500 error on profile" - Then I should see issue "500 error on profile" - - Scenario: Clickable labels - Given issue 'Release 0.4' has label 'bug' - And I visit project "Shop" issues page - When I click label 'bug' - And I should see "Release 0.4" in issues - And I should not see "Tweet control" in issues - - @javascript - Scenario: Issue notes should be editable with +1 - Given project "Shop" have "Release 0.4" open issue - When I visit issue page "Release 0.4" - And I leave a comment with a header containing "Comment with a header" - Then The comment with the header should not have an ID - And I edit the last comment with a +1 - Then I should see +1 in the description - - # Issue description preview - - @javascript - Scenario: I can't preview without text - Given I click link "New Issue" - And I haven't written any description text - Then The Markdown preview tab should say there is nothing to do - - @javascript - Scenario: I can preview with text - Given I click link "New Issue" - And I write a description like ":+1: Nice" - Then The Markdown preview tab should display rendered Markdown - - @javascript - Scenario: I preview an issue description - Given I click link "New Issue" - And I preview a description text like "Bug fixed :smile:" - Then I should see the Markdown preview - And I should not see the Markdown text field - - @javascript - Scenario: I can edit after preview - Given I click link "New Issue" - And I preview a description text like "Bug fixed :smile:" - Then I should see the Markdown write tab - - @javascript - Scenario: I can preview when editing an existing issue - Given I click link "Release 0.4" - And I click link "Edit" for the issue - And I preview a description text like "Bug fixed :smile:" - Then I should see the Markdown write tab - - @javascript - Scenario: I can unsubscribe from issue - Given project "Shop" have "Release 0.4" open issue - When I visit issue page "Release 0.4" - Then I should see that I am subscribed - When I click the subscription toggle - Then I should see that I am unsubscribed - - @javascript - Scenario: I submit new unassigned issue as guest - Given public project "Community" - When I visit project "Community" page - And I visit project "Community" issues page - And I click link "New Issue" - And I should not see assignee field - And I should not see milestone field - And I should not see labels field - And I submit new issue "500 error on profile" - Then I should see issue "500 error on profile" diff --git a/features/project/issues/labels.feature b/features/project/issues/labels.feature deleted file mode 100644 index 45de57f18e3..00000000000 --- a/features/project/issues/labels.feature +++ /dev/null @@ -1,48 +0,0 @@ -@project_issues -Feature: Project Issues Labels - Background: - Given I sign in as a user - And I own project "Shop" - And project "Shop" has labels: "bug", "feature", "enhancement" - Given I visit project "Shop" labels page - - Scenario: I should see labels list - Then I should see label 'bug' - And I should see label 'feature' - - Scenario: I create new label - Given I visit project "Shop" new label page - When I submit new label 'support' - Then I should see label 'support' - - Scenario: I edit label - Given I visit 'bug' label edit page - When I change label 'bug' to 'fix' - Then I should not see label 'bug' - Then I should see label 'fix' - - Scenario: I remove label - When I remove label 'bug' - Then I should not see label 'bug' - - @javascript - Scenario: I remove all labels - When I delete all labels - Then I should see labels help message - - Scenario: I create a label with invalid color - Given I visit project "Shop" new label page - When I submit new label with invalid color - Then I should see label color error message - - Scenario: I create a label that already exists - Given I visit project "Shop" new label page - When I submit new label 'bug' - Then I should see label label exist error message - - Scenario: I create the same label on another project - Given I own project "Forum" - And I visit project "Forum" labels page - And I visit project "Forum" new label page - When I submit new label 'bug' - Then I should see label 'bug' diff --git a/features/project/issues/milestones.feature b/features/project/issues/milestones.feature index d121222308d..77c8ed6e5bf 100644 --- a/features/project/issues/milestones.feature +++ b/features/project/issues/milestones.feature @@ -39,4 +39,5 @@ Feature: Project Issues Milestones Scenario: Headers inside the description should have ids generated for them. Given I click link "v2.2" + # PLEASE USE the `have_header_with_correct_id_and_link(level, text, id, parent)` matcher on migrating this spec to rspec. Then Header "Description header" should have correct id and link diff --git a/features/steps/groups.rb b/features/steps/groups.rb deleted file mode 100644 index 753694a5392..00000000000 --- a/features/steps/groups.rb +++ /dev/null @@ -1,147 +0,0 @@ -class Spinach::Features::Groups < Spinach::FeatureSteps - include SharedAuthentication - include SharedPaths - include SharedGroup - include SharedUser - - step 'I should see group "Owned"' do - expect(page).to have_content 'Owned' - end - - step 'I am a signed out user' do - logout - end - - step 'Group "Owned" has a public project "Public-project"' do - group = owned_group - - @project = create :project, :public, - group: group, - name: "Public-project" - end - - step 'I should see project "Public-project"' do - expect(page).to have_content 'Public-project' - end - - step 'I should see group "Owned" projects list' do - owned_group.projects.each do |project| - expect(page).to have_link project.name - end - end - - step 'I should see projects activity feed' do - expect(page).to have_content 'joined project' - end - - step 'I should see issues from group "Owned" assigned to me' do - assigned_to_me(:issues).each do |issue| - expect(page).to have_content issue.title - end - end - - step 'I should not see issues from the archived project' do - @archived_project.issues.each do |issue| - expect(page).not_to have_content issue.title - end - end - - step 'I should not see merge requests from the archived project' do - @archived_project.merge_requests.each do |mr| - expect(page).not_to have_content mr.title - end - end - - step 'I should see merge requests from group "Owned" assigned to me' do - assigned_to_me(:merge_requests).each do |issue| - expect(page).to have_content issue.title[0..80] - end - end - - step 'project from group "Owned" has issues assigned to me' do - create :issue, - project: project, - assignees: [current_user], - author: current_user - end - - step 'project from group "Owned" has merge requests assigned to me' do - create :merge_request, - source_project: project, - target_project: project, - assignee: current_user, - author: current_user - end - - step 'I change group "Owned" avatar' do - attach_file(:group_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')) - click_button "Save group" - owned_group.reload - end - - step 'I should see new group "Owned" avatar' do - expect(owned_group.avatar).to be_instance_of AvatarUploader - expect(owned_group.avatar.url).to eq "/uploads/-/system/group/avatar/#{Group.find_by(name: "Owned").id}/banana_sample.gif" - end - - step 'I should see the "Remove avatar" button' do - expect(page).to have_link("Remove avatar") - end - - step 'I have group "Owned" avatar' do - attach_file(:group_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')) - click_button "Save group" - owned_group.reload - end - - step 'I remove group "Owned" avatar' do - click_link "Remove avatar" - owned_group.reload - end - - step 'I should not see group "Owned" avatar' do - expect(owned_group.avatar?).to eq false - end - - step 'I should not see the "Remove avatar" button' do - expect(page).not_to have_link("Remove avatar") - end - - step 'Group "Owned" has archived project' do - group = Group.find_by(name: 'Owned') - @archived_project = create(:project, :archived, namespace: group, path: "archived-project") - end - - step 'I should see "archived" label' do - expect(page).to have_xpath("//span[@class='label label-warning']", text: 'archived') - end - - step 'I visit group "NonExistentGroup" page' do - visit group_path("NonExistentGroup") - end - - step 'the archived project have some issues' do - create :issue, - project: @archived_project, - assignees: [current_user], - author: current_user - end - - step 'the archived project have some merge requests' do - create :merge_request, - source_project: @archived_project, - target_project: @archived_project, - assignee: current_user, - author: current_user - end - - private - - def assigned_to_me(key) - project.send(key).assigned_to(current_user) - end - - def project - owned_group.projects.first - end -end diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index 3cd26bb429b..baa78c23203 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -7,36 +7,14 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps include SharedMarkdown include SharedUser - step 'I should see "Release 0.4" in issues' do - expect(page).to have_content "Release 0.4" - end - step 'I should not see "Release 0.3" in issues' do expect(page).not_to have_content "Release 0.3" end - step 'I should not see "Tweet control" in issues' do - expect(page).not_to have_content "Tweet control" - end - - step 'I should see that I am subscribed' do - wait_for_requests - expect(find('.js-issuable-subscribe-button')).to have_css 'button.is-checked' - end - - step 'I should see that I am unsubscribed' do - wait_for_requests - expect(find('.js-issuable-subscribe-button')).to have_css 'button:not(.is-checked)' - end - step 'I click link "Closed"' do find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click end - step 'I click the subscription toggle' do - find('.js-issuable-subscribe-button button').click - end - step 'I should see "Release 0.3" in issues' do expect(page).to have_content "Release 0.3" end @@ -51,24 +29,10 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps expect(find('.issues-state-filters > .active')).to have_content 'All' end - step 'I click link "Release 0.4"' do - click_link "Release 0.4" - end - - step 'I should see issue "Release 0.4"' do - expect(page).to have_content "Release 0.4" - end - step 'I should see issue "Tweet control"' do expect(page).to have_content "Tweet control" end - step 'I click link "New issue"' do - 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 page.find('.js-author-search').click sleep 1 @@ -81,18 +45,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps expect(users[1].text).to eq "#{current_user.name} #{current_user.to_reference}" end - step 'I submit new issue "500 error on profile"' do - fill_in "issue_title", with: "500 error on profile" - click_button "Submit issue" - end - - step 'I submit new issue "500 error on profile" with label \'bug\'' do - fill_in "issue_title", with: "500 error on profile" - click_button "Label" - click_link "bug" - click_button "Submit issue" - end - step 'I click link "500 error on profile"' do click_link "500 error on profile" end @@ -103,13 +55,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end end - step 'I should see issue "500 error on profile"' do - issue = Issue.find_by(title: "500 error on profile") - expect(page).to have_content issue.title - expect(page).to have_content issue.author_name - expect(page).to have_content issue.project.name - end - step 'I fill in issue search with "Re"' do filter_issue "Re" end @@ -163,49 +108,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps expect(find(issues_assignee_selector)).to have_content(assignee_name) end - step 'project "Shop" have "Release 0.4" open issue' do - create(:issue, - title: "Release 0.4", - project: project, - author: project.users.first, - description: "# Description header" - ) - wait_for_requests - end - - step 'project "Shop" have "Tweet control" open issue' do - create(:issue, - title: "Tweet control", - project: project, - author: project.users.first) - end - - step 'project "Shop" have "Bugfix" open issue' do - create(:issue, - title: "Bugfix", - project: project, - author: project.users.first) - end - - step 'project "Shop" have "Release 0.3" closed issue' do - create(:closed_issue, - title: "Release 0.3", - project: project, - author: project.users.first) - end - - step 'issue "Release 0.4" have 2 upvotes and 1 downvote' do - awardable = Issue.find_by(title: 'Release 0.4') - create_list(:award_emoji, 2, awardable: awardable) - create(:award_emoji, :downvote, awardable: awardable) - end - - step 'issue "Tweet control" have 1 upvote and 2 downvotes' do - awardable = Issue.find_by(title: 'Tweet control') - create(:award_emoji, :upvote, awardable: awardable) - create_list(:award_emoji, 2, awardable: awardable, name: 'thumbsdown') - end - step 'The list should be sorted by "Least popular"' do page.within '.issues-list' do page.within 'li.issue:nth-child(1)' do @@ -225,69 +127,16 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end end - step 'The list should be sorted by "Popularity"' do - page.within '.issues-list' do - page.within 'li.issue:nth-child(1)' do - expect(page).to have_content 'Release 0.4' - expect(page).to have_content '2 1' - end - - page.within 'li.issue:nth-child(2)' do - expect(page).to have_content 'Tweet control' - expect(page).to have_content '1 2' - end - - page.within 'li.issue:nth-child(3)' do - expect(page).to have_content 'Bugfix' - expect(page).not_to have_content '0 0' - end - end - end - - step 'empty project "Empty Project"' do - create :project_empty_repo, name: 'Empty Project', namespace: @user.namespace - end - When 'I visit empty project page' do project = Project.find_by(name: 'Empty Project') visit project_path(project) end - step 'I see empty project details with ssh clone info' do - project = Project.find_by(name: 'Empty Project') - page.all(:css, '.git-empty .clone').each do |element| - expect(element.text).to include(project.url_to_repo) - end - end - When "I visit project \"Community\" issues page" do project = Project.find_by(name: 'Community') visit project_issues_path(project) end - When "I visit empty project's issues page" do - project = Project.find_by(name: 'Empty Project') - visit project_issues_path(project) - end - - step 'I leave a comment with code block' do - page.within(".js-main-target-form") do - fill_in "note[note]", with: "```\nCommand [1]: /usr/local/bin/git , see [text](doc/text)\n```" - click_button "Comment" - sleep 0.05 - end - end - - step 'I should see an error alert section within the comment form' do - page.within(".js-main-target-form") do - find(".error-alert") - end - end - - step 'The code block should be unchanged' do - expect(page).to have_content("```\nCommand [1]: /usr/local/bin/git , see [text](doc/text)\n```") - end - step 'project \'Shop\' has issue \'Bugfix1\' with description: \'Description for issue1\'' do create(:issue, title: 'Bugfix1', description: 'Description for issue1', project: project) end @@ -320,36 +169,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps expect(page).not_to have_content 'Bugfix1' end - step 'issue \'Release 0.4\' has label \'bug\'' do - label = project.labels.create!(name: 'bug', color: '#990000') - issue = Issue.find_by!(title: 'Release 0.4') - issue.labels << label - end - - step 'I click label \'bug\'' do - page.within ".issues-list" do - click_link 'bug' - end - end - - step 'I should not see labels field' do - page.within '.issue-form' do - expect(page).not_to have_content("Labels") - end - end - - step 'I should not see milestone field' do - page.within '.issue-form' do - expect(page).not_to have_content("Milestone") - end - end - - step 'I should not see assignee field' do - page.within '.issue-form' do - expect(page).not_to have_content("Assign to") - end - end - def filter_issue(text) fill_in 'issuable_search', with: text end diff --git a/features/steps/project/issues/labels.rb b/features/steps/project/issues/labels.rb deleted file mode 100644 index 4df96e081f9..00000000000 --- a/features/steps/project/issues/labels.rb +++ /dev/null @@ -1,101 +0,0 @@ -class Spinach::Features::ProjectIssuesLabels < Spinach::FeatureSteps - include SharedAuthentication - include SharedProject - include SharedPaths - - step 'I visit \'bug\' label edit page' do - visit edit_project_label_path(project, bug_label) - end - - step 'I remove label \'bug\'' do - page.within "#project_label_#{bug_label.id}" do - first(:link, 'Delete').click - end - end - - step 'I delete all labels' do - page.within '.labels' do - page.all('.label-list-item').each do - first('.remove-row').click - first(:link, 'Delete label').click - end - end - end - - step 'I should see labels help message' do - page.within '.labels' do - expect(page).to have_content 'Generate a default set of labels' - expect(page).to have_content 'New label' - end - end - - step 'I submit new label \'support\'' do - fill_in 'Title', with: 'support' - fill_in 'Background color', with: '#F95610' - click_button 'Create label' - end - - step 'I submit new label \'bug\'' do - fill_in 'Title', with: 'bug' - fill_in 'Background color', with: '#F95610' - click_button 'Create label' - end - - step 'I submit new label with invalid color' do - fill_in 'Title', with: 'support' - fill_in 'Background color', with: '#12' - click_button 'Create label' - end - - step 'I should see label label exist error message' do - page.within '.label-form' do - expect(page).to have_content 'Title has already been taken' - end - end - - step 'I should see label color error message' do - page.within '.label-form' do - expect(page).to have_content 'Color must be a valid color code' - end - end - - step 'I should see label \'feature\'' do - page.within '.other-labels .manage-labels-list' do - expect(page).to have_content 'feature' - end - end - - step 'I should see label \'bug\'' do - page.within '.other-labels .manage-labels-list' do - expect(page).to have_content 'bug' - end - end - - step 'I should not see label \'bug\'' do - page.within '.other-labels .manage-labels-list' do - expect(page).not_to have_content 'bug' - end - end - - step 'I should see label \'support\'' do - page.within '.other-labels .manage-labels-list' do - expect(page).to have_content 'support' - end - end - - step 'I change label \'bug\' to \'fix\'' do - fill_in 'Title', with: 'fix' - fill_in 'Background color', with: '#F15610' - click_button 'Save changes' - end - - step 'I should see label \'fix\'' do - page.within '.other-labels .manage-labels-list' do - expect(page).to have_content 'fix' - end - end - - def bug_label - project.labels.find_or_create_by(title: 'bug') - end -end diff --git a/features/steps/project/project.rb b/features/steps/project/project.rb index 3a762be8f1f..bba30a72325 100644 --- a/features/steps/project/project.rb +++ b/features/steps/project/project.rb @@ -143,7 +143,7 @@ class Spinach::Features::Project < Spinach::FeatureSteps end step 'I create bare repo' do - click_link 'Create empty bare repository' + click_link 'Create empty repository' end step 'I should see command line instructions' do diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb index f90247c3fe8..a9174efd334 100644 --- a/features/steps/shared/issuable.rb +++ b/features/steps/shared/issuable.rb @@ -105,17 +105,6 @@ module SharedIssuable edit_issuable end - step 'I click link "Edit" for the issue' do - edit_issuable - end - - step 'I sort the list by "Last updated"' do - find('button.dropdown-toggle').click - page.within('.content ul.dropdown-menu.dropdown-menu-align-right li') do - click_link "Last updated" - end - end - step 'I sort the list by "Least popular"' do find('button.dropdown-toggle').click @@ -124,18 +113,6 @@ module SharedIssuable end end - step 'I sort the list by "Popularity"' do - find('button.dropdown-toggle').click - - page.within('.content ul.dropdown-menu.dropdown-menu-align-right li') do - click_link 'Popularity' - end - end - - step 'The list should be sorted by "Last updated"' do - expect(find('.issues-filters')).to have_content('Last updated') - end - step 'I click link "Next" in the sidebar' do page.within '.issuable-sidebar' do click_link 'Next' diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb index c2bec2a6320..c66280127e9 100644 --- a/features/steps/shared/markdown.rb +++ b/features/steps/shared/markdown.rb @@ -18,43 +18,6 @@ module SharedMarkdown expect(find('.gfm-form .js-md-preview')).not_to be_visible end - step 'The Markdown preview tab should say there is nothing to do' do - page.within('.gfm-form') do - find('.js-md-preview-button').click - expect(find('.js-md-preview')).to have_content('Nothing to preview.') - end - end - - step 'I should not see the Markdown text field' do - expect(find('.gfm-form textarea')).not_to be_visible - end - - step 'I should see the Markdown write tab' do - expect(first('.gfm-form')).to have_link('Write', visible: true) - end - - step 'I should see the Markdown preview' do - expect(find('.gfm-form')).to have_css('.js-md-preview', visible: true) - end - - step 'The Markdown preview tab should display rendered Markdown' do - page.within('.gfm-form') do - find('.js-md-preview-button').click - expect(find('.js-md-preview')).to have_css('gl-emoji', visible: true) - end - end - - step 'I write a description like ":+1: Nice"' do - find('.gfm-form').fill_in 'Description', with: ':+1: Nice' - end - - step 'I preview a description text like "Bug fixed :smile:"' do - page.within(first('.gfm-form')) do - fill_in 'Description', with: 'Bug fixed :smile:' - click_link 'Preview' - end - end - step 'I haven\'t written any description text' do find('.gfm-form').fill_in 'Description', with: '' end diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb index 95f0cd2156e..cbe1cae096e 100644 --- a/features/steps/shared/note.rb +++ b/features/steps/shared/note.rb @@ -114,34 +114,12 @@ module SharedNote end end - step 'I should see comment "XML attached"' do - page.within(".note") do - expect(page).to have_content("XML attached") - end - end - step 'I should see no notes at all' do expect(page).not_to have_css('.note') end # Markdown - step 'I leave a comment with a header containing "Comment with a header"' do - page.within(".js-main-target-form") do - fill_in "note[note]", with: "# Comment with a header" - click_button "Comment" - end - - wait_for_requests - end - - step 'The comment with the header should not have an ID' do - page.within(".note-body > .note-text") do - expect(page).to have_content("Comment with a header") - expect(page).not_to have_css("#comment-with-a-header") - end - end - step 'I edit the last comment with a +1' do page.within(".main-notes-list") do note = find('.note') diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index bff0d58aaf4..cc893b8391e 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -96,10 +96,6 @@ module SharedPaths visit assigned_issues_dashboard_path end - step 'I visit dashboard merge requests page' do - visit assigned_mrs_dashboard_path - end - step 'I visit dashboard search page' do visit search_path end @@ -200,10 +196,6 @@ module SharedPaths # Generic Project # ---------------------------------------- - step "I visit my project's home page" do - visit project_path(@project) - end - step "I visit my project's settings page" do visit edit_project_path(@project) end @@ -339,20 +331,11 @@ module SharedPaths visit project_commit_path(@project, sample_commit.id) end - step 'I visit project "Shop" issues page' do - visit project_issues_path(project) - end - step 'I visit issue page "Release 0.4"' do issue = Issue.find_by(title: "Release 0.4") visit project_issue_path(issue.project, issue) end - step 'I visit project "Shop" labels page' do - project = Project.find_by(name: 'Shop') - visit project_labels_path(project) - end - step 'I visit project "Forum" labels page' do project = Project.find_by(name: 'Forum') visit project_labels_path(project) @@ -394,10 +377,6 @@ module SharedPaths wait_for_requests end - step 'I visit project "Shop" merge requests page' do - visit project_merge_requests_path(project) - end - step 'I visit forked project "Shop" merge requests page' do visit project_merge_requests_path(project) end @@ -418,11 +397,6 @@ module SharedPaths # Visibility Projects # ---------------------------------------- - step 'I visit project "Community" page' do - project = Project.find_by(name: "Community") - visit project_path(project) - end - step 'I visit project "Community" source page' do project = Project.find_by(name: 'Community') visit project_tree_path(project, root_ref) @@ -442,11 +416,6 @@ module SharedPaths # Empty Projects # ---------------------------------------- - step "I visit empty project page" do - project = Project.find_by(name: "Empty Public Project") - visit project_path(project) - end - step "I should not see command line instructions" do expect(page).not_to have_css('.empty_wrapper') end diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb index 07a0e2e072c..be848ebafa0 100644 --- a/features/steps/shared/project.rb +++ b/features/steps/shared/project.rb @@ -236,10 +236,6 @@ module SharedProject @project.update(public_builds: false) end - step 'project "Shop" has a "Bugfix MR" merge request open' do - create(:merge_request, title: "Bugfix MR", target_project: project, source_project: project, author: project.users.first) - end - def user_owns_project(user_name:, project_name:, visibility: :private) user = user_exists(user_name, username: user_name.gsub(/\s/, '').underscore) project = Project.find_by(name: project_name) diff --git a/features/steps/shared/user.rb b/features/steps/shared/user.rb index 9856c510aa0..9cadc91769d 100644 --- a/features/steps/shared/user.rb +++ b/features/steps/shared/user.rb @@ -19,10 +19,6 @@ module SharedUser User.find_by(name: name) || create(:user, { name: name, admin: false }.merge(options)) end - step 'I have an ssh key' do - create(:personal_key, user: @user) - end - step 'I have no ssh keys' do @user.keys.delete_all end diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb index b0b7b50998f..70d43ac1d79 100644 --- a/lib/api/deploy_keys.rb +++ b/lib/api/deploy_keys.rb @@ -54,7 +54,7 @@ module API present key, with: Entities::DeployKeysProject end - desc 'Add new deploy key to currently authenticated user' do + desc 'Add new deploy key to a project' do success Entities::DeployKeysProject end params do @@ -66,33 +66,32 @@ module API params[:key].strip! # Check for an existing key joined to this project - key = user_project.deploy_keys_projects + deploy_key_project = user_project.deploy_keys_projects .joins(:deploy_key) .find_by(keys: { key: params[:key] }) - if key - present key, with: Entities::DeployKeysProject + if deploy_key_project + present deploy_key_project, with: Entities::DeployKeysProject break end # Check for available deploy keys in other projects key = current_user.accessible_deploy_keys.find_by(key: params[:key]) if key - added_key = add_deploy_keys_project(user_project, deploy_key: key, can_push: !!params[:can_push]) + deploy_key_project = add_deploy_keys_project(user_project, deploy_key: key, can_push: !!params[:can_push]) - present added_key, with: Entities::DeployKeysProject + present deploy_key_project, with: Entities::DeployKeysProject break end # Create a new deploy key - key_attributes = { can_push: !!params[:can_push], - deploy_key_attributes: declared_params.except(:can_push) } - key = add_deploy_keys_project(user_project, key_attributes) + deploy_key_attributes = declared_params.except(:can_push).merge(user: current_user) + deploy_key_project = add_deploy_keys_project(user_project, deploy_key_attributes: deploy_key_attributes, can_push: !!params[:can_push]) - if key.valid? - present key, with: Entities::DeployKeysProject + if deploy_key_project.valid? + present deploy_key_project, with: Entities::DeployKeysProject else - render_validation_error!(key) + render_validation_error!(deploy_key_project) end end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 16147ee90c9..e5ecd37e473 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -206,6 +206,7 @@ module API expose :request_access_enabled expose :only_allow_merge_if_all_discussions_are_resolved expose :printing_merge_request_link_enabled + expose :merge_method expose :statistics, using: 'API::Entities::ProjectStatistics', if: :statistics @@ -405,6 +406,7 @@ module API class IssueBasic < ProjectEntity expose :closed_at + expose :closed_by, using: Entities::UserBasic expose :labels do |issue, options| # Avoids an N+1 query since labels are preloaded issue.labels.map(&:title).sort @@ -951,6 +953,7 @@ module API expose :tag_list expose :run_untagged expose :locked + expose :maximum_timeout expose :access_level expose :version, :revision, :platform, :architecture expose :contacted_at @@ -1119,7 +1122,7 @@ module API end class RunnerInfo < Grape::Entity - expose :timeout + expose :metadata_timeout, as: :timeout end class Step < Grape::Entity diff --git a/lib/api/features.rb b/lib/api/features.rb index 9385c6ca174..11d848584d9 100644 --- a/lib/api/features.rb +++ b/lib/api/features.rb @@ -65,6 +65,13 @@ module API present feature, with: Entities::Feature, current_user: current_user end + + desc 'Remove the gate value for the given feature' + delete ':name' do + Feature.get(params[:name]).remove + + status 204 + end end end end diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index 14648588dfd..abe3d353984 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -29,18 +29,6 @@ module API {} end - def fix_git_env_repository_paths(env, repository_path) - if obj_dir_relative = env['GIT_OBJECT_DIRECTORY_RELATIVE'].presence - env['GIT_OBJECT_DIRECTORY'] = File.join(repository_path, obj_dir_relative) - end - - if alt_obj_dirs_relative = env['GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE'].presence - env['GIT_ALTERNATE_OBJECT_DIRECTORIES'] = alt_obj_dirs_relative.map { |dir| File.join(repository_path, dir) } - end - - env - end - def log_user_activity(actor) commands = Gitlab::GitAccess::DOWNLOAD_COMMANDS diff --git a/lib/api/internal.rb b/lib/api/internal.rb index b3660e4a1d0..fcbc248fc3b 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -21,8 +21,7 @@ module API # Stores some Git-specific env thread-safely env = parse_env - env = fix_git_env_repository_paths(env, repository_path) if project - Gitlab::Git::Env.set(env) + Gitlab::Git::HookEnv.set(gl_repository, env) if project actor = if params[:key_id] diff --git a/lib/api/project_export.rb b/lib/api/project_export.rb index efc4a33ae1b..5ef4e9d530c 100644 --- a/lib/api/project_export.rb +++ b/lib/api/project_export.rb @@ -33,11 +33,28 @@ module API end params do optional :description, type: String, desc: 'Override the project description' + optional :upload, type: Hash do + optional :url, type: String, desc: 'The URL to upload the project' + optional :http_method, type: String, default: 'PUT', desc: 'HTTP method to upload the exported project' + end end post ':id/export' do project_export_params = declared_params(include_missing: false) + after_export_params = project_export_params.delete(:upload) || {} - user_project.add_export_job(current_user: current_user, params: project_export_params) + export_strategy = if after_export_params[:url].present? + params = after_export_params.slice(:url, :http_method).symbolize_keys + + Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy.new(params) + end + + if export_strategy&.invalid? + render_validation_error!(export_strategy) + else + user_project.add_export_job(current_user: current_user, + after_export_strategy: export_strategy, + params: project_export_params) + end accepted! end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 467bc78dad8..3d5b3c5a535 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -28,6 +28,7 @@ module API optional :tag_list, type: Array[String], desc: 'The list of tags for a project' optional :avatar, type: File, desc: 'Avatar image for project' optional :printing_merge_request_link_enabled, type: Boolean, desc: 'Show link to create/view merge request when pushing from the command line' + optional :merge_method, type: String, values: %w(ff rebase_merge merge), desc: 'The merge method used when merging merge requests' end params :optional_params do @@ -274,6 +275,7 @@ module API :issues_enabled, :lfs_enabled, :merge_requests_enabled, + :merge_method, :name, :only_allow_merge_if_all_discussions_are_resolved, :only_allow_merge_if_pipeline_succeeds, diff --git a/lib/api/runner.rb b/lib/api/runner.rb index 8da97a97754..834253d8e94 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -14,9 +14,10 @@ module API optional :locked, type: Boolean, desc: 'Should Runner be locked for current project' optional :run_untagged, type: Boolean, desc: 'Should Runner handle untagged jobs' optional :tag_list, type: Array[String], desc: %q(List of Runner's tags) + optional :maximum_timeout, type: Integer, desc: 'Maximum timeout set when this Runner will handle the job' end post '/' do - attributes = attributes_for_keys([:description, :locked, :run_untagged, :tag_list]) + attributes = attributes_for_keys([:description, :locked, :run_untagged, :tag_list, :maximum_timeout]) .merge(get_runner_details_from_request) runner = @@ -207,6 +208,7 @@ module API optional 'file.sha256', type: String, desc: %q(sha256 checksum of the file) optional 'metadata.path', type: String, desc: %q(path to locally stored body (generated by Workhorse)) optional 'metadata.name', type: String, desc: %q(filename (generated by Workhorse)) + optional 'metadata.sha256', type: String, desc: %q(sha256 checksum of the file) end post '/:id/artifacts' do not_allowed! unless Gitlab.config.artifacts.enabled @@ -226,7 +228,7 @@ module API Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in job.build_job_artifacts_archive(project: job.project, file_type: :archive, file: artifacts, file_sha256: params['file.sha256'], expire_in: expire_in) - job.build_job_artifacts_metadata(project: job.project, file_type: :metadata, file: metadata, expire_in: expire_in) if metadata + job.build_job_artifacts_metadata(project: job.project, file_type: :metadata, file: metadata, file_sha256: params['metadata.sha256'], expire_in: expire_in) if metadata job.artifacts_expire_in = expire_in if job.save diff --git a/lib/api/runners.rb b/lib/api/runners.rb index 996457c5dfe..5f2a9567605 100644 --- a/lib/api/runners.rb +++ b/lib/api/runners.rb @@ -57,6 +57,7 @@ module API optional :locked, type: Boolean, desc: 'Flag indicating the runner is locked' optional :access_level, type: String, values: Ci::Runner.access_levels.keys, desc: 'The access_level of the runner' + optional :maximum_timeout, type: Integer, desc: 'Maximum timeout set when this Runner will handle the job' at_least_one_of :description, :active, :tag_list, :run_untagged, :locked, :access_level end put ':id' do diff --git a/lib/backup/artifacts.rb b/lib/backup/artifacts.rb index 4383124d150..6a5a223a614 100644 --- a/lib/backup/artifacts.rb +++ b/lib/backup/artifacts.rb @@ -5,9 +5,5 @@ module Backup def initialize super('artifacts', JobArtifactUploader.root) end - - def create_files_dir - Dir.mkdir(app_files_dir, 0700) - end end end diff --git a/lib/backup/builds.rb b/lib/backup/builds.rb index 635967f4bd4..f869916e199 100644 --- a/lib/backup/builds.rb +++ b/lib/backup/builds.rb @@ -5,9 +5,5 @@ module Backup def initialize super('builds', Settings.gitlab_ci.builds_path) end - - def create_files_dir - Dir.mkdir(app_files_dir, 0700) - end end end diff --git a/lib/backup/files.rb b/lib/backup/files.rb index 287d591e88d..88cb7e7b5a4 100644 --- a/lib/backup/files.rb +++ b/lib/backup/files.rb @@ -1,7 +1,10 @@ require 'open3' +require_relative 'helper' module Backup class Files + include Backup::Helper + attr_reader :name, :app_files_dir, :backup_tarball, :files_parent_dir def initialize(name, app_files_dir) @@ -35,15 +38,22 @@ module Backup def restore backup_existing_files_dir - create_files_dir - run_pipeline!([%w(gzip -cd), %W(tar -C #{app_files_dir} -xf -)], in: backup_tarball) + run_pipeline!([%w(gzip -cd), %W(tar --unlink-first --recursive-unlink -C #{app_files_dir} -xf -)], in: backup_tarball) end def backup_existing_files_dir - timestamped_files_path = File.join(files_parent_dir, "#{name}.#{Time.now.to_i}") + timestamped_files_path = File.join(Gitlab.config.backup.path, "tmp", "#{name}.#{Time.now.to_i}") if File.exist?(app_files_dir) - FileUtils.mv(app_files_dir, File.expand_path(timestamped_files_path)) + # Move all files in the existing repos directory except . and .. to + # repositories.old.<timestamp> directory + FileUtils.mkdir_p(timestamped_files_path, mode: 0700) + files = Dir.glob(File.join(app_files_dir, "*"), File::FNM_DOTMATCH) - [File.join(app_files_dir, "."), File.join(app_files_dir, "..")] + begin + FileUtils.mv(files, timestamped_files_path) + rescue Errno::EACCES + access_denied_error(app_files_dir) + end end end diff --git a/lib/backup/helper.rb b/lib/backup/helper.rb new file mode 100644 index 00000000000..a1ee0faefe9 --- /dev/null +++ b/lib/backup/helper.rb @@ -0,0 +1,17 @@ +module Backup + module Helper + def access_denied_error(path) + message = <<~EOS + + ### NOTICE ### + As part of restore, the task tried to move existing content from #{path}. + However, it seems that directory contains files/folders that are not owned + by the user #{Gitlab.config.gitlab.user}. To proceed, please move the files + or folders inside #{path} to a secure location so that #{path} is empty and + run restore task again. + + EOS + raise message + end + end +end diff --git a/lib/backup/lfs.rb b/lib/backup/lfs.rb index 4153467fbee..4e234e50a7a 100644 --- a/lib/backup/lfs.rb +++ b/lib/backup/lfs.rb @@ -5,9 +5,5 @@ module Backup def initialize super('lfs', Settings.lfs.storage_path) end - - def create_files_dir - Dir.mkdir(app_files_dir, 0700) - end end end diff --git a/lib/backup/pages.rb b/lib/backup/pages.rb index 215ded93bfe..5830b209d6e 100644 --- a/lib/backup/pages.rb +++ b/lib/backup/pages.rb @@ -5,9 +5,5 @@ module Backup def initialize super('pages', Gitlab.config.pages.path) end - - def create_files_dir - Dir.mkdir(app_files_dir, 0700) - end end end diff --git a/lib/backup/registry.rb b/lib/backup/registry.rb index 67fe0231087..91698669402 100644 --- a/lib/backup/registry.rb +++ b/lib/backup/registry.rb @@ -5,9 +5,5 @@ module Backup def initialize super('registry', Settings.registry.path) end - - def create_files_dir - Dir.mkdir(app_files_dir, 0700) - end end end diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb index 88a7f2a4235..89e3f1d9076 100644 --- a/lib/backup/repository.rb +++ b/lib/backup/repository.rb @@ -1,8 +1,11 @@ require 'yaml' +require_relative 'helper' module Backup class Repository + include Backup::Helper # rubocop:disable Metrics/AbcSize + def dump prepare @@ -63,18 +66,27 @@ module Backup end end - def restore + def prepare_directories Gitlab.config.repositories.storages.each do |name, repository_storage| path = repository_storage.legacy_disk_path next unless File.exist?(path) - # Move repos dir to 'repositories.old' dir - bk_repos_path = File.join(path, '..', 'repositories.old.' + Time.now.to_i.to_s) - FileUtils.mv(path, bk_repos_path) - # This is expected from gitlab:check - FileUtils.mkdir_p(path, mode: 02770) + # Move all files in the existing repos directory except . and .. to + # repositories.old.<timestamp> directory + bk_repos_path = File.join(Gitlab.config.backup.path, "tmp", "#{name}-repositories.old." + Time.now.to_i.to_s) + FileUtils.mkdir_p(bk_repos_path, mode: 0700) + files = Dir.glob(File.join(path, "*"), File::FNM_DOTMATCH) - [File.join(path, "."), File.join(path, "..")] + + begin + FileUtils.mv(files, bk_repos_path) + rescue Errno::EACCES + access_denied_error(path) + end end + end + def restore + prepare_directories Project.find_each(batch_size: 1000) do |project| progress.print " * #{display_repo_path(project)} ... " path_to_project_repo = path_to_repo(project) diff --git a/lib/backup/uploads.rb b/lib/backup/uploads.rb index 35118375499..d46e2cd869d 100644 --- a/lib/backup/uploads.rb +++ b/lib/backup/uploads.rb @@ -5,9 +5,5 @@ module Backup def initialize super('uploads', Rails.root.join('public/uploads')) end - - def create_files_dir - Dir.mkdir(app_files_dir) - end end end diff --git a/lib/banzai/filter/emoji_filter.rb b/lib/banzai/filter/emoji_filter.rb index b82c6ca6393..e1261e7bbbe 100644 --- a/lib/banzai/filter/emoji_filter.rb +++ b/lib/banzai/filter/emoji_filter.rb @@ -11,7 +11,7 @@ module Banzai IGNORED_ANCESTOR_TAGS = %w(pre code tt).to_set def call - search_text_nodes(doc).each do |node| + doc.search(".//text()").each do |node| content = node.to_html next if has_ancestor?(node, IGNORED_ANCESTOR_TAGS) diff --git a/lib/banzai/filter/gollum_tags_filter.rb b/lib/banzai/filter/gollum_tags_filter.rb index c2b42673376..f2e9a5a1116 100644 --- a/lib/banzai/filter/gollum_tags_filter.rb +++ b/lib/banzai/filter/gollum_tags_filter.rb @@ -57,7 +57,7 @@ module Banzai ALLOWED_IMAGE_EXTENSIONS = /.+(jpg|png|gif|svg|bmp)\z/i.freeze def call - search_text_nodes(doc).each do |node| + doc.search(".//text()").each do |node| # A Gollum ToC tag is `[[_TOC_]]`, but due to MarkdownFilter running # before this one, it will be converted into `[[<em>TOC</em>]]`, so it # needs special-case handling diff --git a/lib/banzai/filter/inline_diff_filter.rb b/lib/banzai/filter/inline_diff_filter.rb index beb21b19ab3..73e82a4d7e3 100644 --- a/lib/banzai/filter/inline_diff_filter.rb +++ b/lib/banzai/filter/inline_diff_filter.rb @@ -4,7 +4,7 @@ module Banzai IGNORED_ANCESTOR_TAGS = %w(pre code tt).to_set def call - search_text_nodes(doc).each do |node| + doc.search(".//text()").each do |node| next if has_ancestor?(node, IGNORED_ANCESTOR_TAGS) content = node.to_html diff --git a/lib/gitlab/auth/ldap/access.rb b/lib/gitlab/auth/ldap/access.rb index 77c0ddc2d48..34286900e72 100644 --- a/lib/gitlab/auth/ldap/access.rb +++ b/lib/gitlab/auth/ldap/access.rb @@ -52,6 +52,8 @@ module Gitlab block_user(user, 'does not exist anymore') false end + rescue LDAPConnectionError + false end def adapter diff --git a/lib/gitlab/auth/ldap/adapter.rb b/lib/gitlab/auth/ldap/adapter.rb index caf2d18c668..82ff1e77e5c 100644 --- a/lib/gitlab/auth/ldap/adapter.rb +++ b/lib/gitlab/auth/ldap/adapter.rb @@ -2,6 +2,9 @@ module Gitlab module Auth module LDAP class Adapter + SEARCH_RETRY_FACTOR = [1, 1, 2, 3].freeze + MAX_SEARCH_RETRIES = Rails.env.test? ? 1 : SEARCH_RETRY_FACTOR.size.freeze + attr_reader :provider, :ldap def self.open(provider, &block) @@ -16,7 +19,7 @@ module Gitlab def initialize(provider, ldap = nil) @provider = provider - @ldap = ldap || Net::LDAP.new(config.adapter_options) + @ldap = ldap || renew_connection_adapter end def config @@ -47,8 +50,10 @@ module Gitlab end def ldap_search(*args) + retries ||= 0 + # Net::LDAP's `time` argument doesn't work. Use Ruby `Timeout` instead. - Timeout.timeout(config.timeout) do + Timeout.timeout(timeout_time(retries)) do results = ldap.search(*args) if results.nil? @@ -63,16 +68,26 @@ module Gitlab results end end - rescue Net::LDAP::Error => error - Rails.logger.warn("LDAP search raised exception #{error.class}: #{error.message}") - [] - rescue Timeout::Error - Rails.logger.warn("LDAP search timed out after #{config.timeout} seconds") - [] + rescue Net::LDAP::Error, Timeout::Error => error + retries += 1 + error_message = connection_error_message(error) + + Rails.logger.warn(error_message) + + if retries < MAX_SEARCH_RETRIES + renew_connection_adapter + retry + else + raise LDAPConnectionError, error_message + end end private + def timeout_time(retry_number) + SEARCH_RETRY_FACTOR[retry_number] * config.timeout + end + def user_options(fields, value, limit) options = { attributes: Gitlab::Auth::LDAP::Person.ldap_attributes(config), @@ -104,6 +119,18 @@ module Gitlab filter end end + + def connection_error_message(exception) + if exception.is_a?(Timeout::Error) + "LDAP search timed out after #{config.timeout} seconds" + else + "LDAP search raised exception #{exception.class}: #{exception.message}" + end + end + + def renew_connection_adapter + @ldap = Net::LDAP.new(config.adapter_options) + end end end end diff --git a/lib/gitlab/auth/ldap/ldap_connection_error.rb b/lib/gitlab/auth/ldap/ldap_connection_error.rb new file mode 100644 index 00000000000..ef0a695742b --- /dev/null +++ b/lib/gitlab/auth/ldap/ldap_connection_error.rb @@ -0,0 +1,7 @@ +module Gitlab + module Auth + module LDAP + LDAPConnectionError = Class.new(StandardError) + end + end +end diff --git a/lib/gitlab/auth/o_auth/user.rb b/lib/gitlab/auth/o_auth/user.rb index b6a96081278..d0c6b0386ba 100644 --- a/lib/gitlab/auth/o_auth/user.rb +++ b/lib/gitlab/auth/o_auth/user.rb @@ -124,6 +124,9 @@ module Gitlab Gitlab::Auth::LDAP::Person.find_by_uid(auth_hash.uid, adapter) || Gitlab::Auth::LDAP::Person.find_by_email(auth_hash.uid, adapter) || Gitlab::Auth::LDAP::Person.find_by_dn(auth_hash.uid, adapter) + + rescue Gitlab::Auth::LDAP::LDAPConnectionError + nil end def ldap_config diff --git a/lib/gitlab/background_migration/migrate_build_stage.rb b/lib/gitlab/background_migration/migrate_build_stage.rb index 8fe4f1a2289..242e3143e71 100644 --- a/lib/gitlab/background_migration/migrate_build_stage.rb +++ b/lib/gitlab/background_migration/migrate_build_stage.rb @@ -12,6 +12,7 @@ module Gitlab class Build < ActiveRecord::Base self.table_name = 'ci_builds' + self.inheritance_column = :_type_disabled def ensure_stage!(attempts: 2) find_stage || create_stage! diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index bffbcb86137..f3999e690fa 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -63,7 +63,7 @@ module Gitlab disk_path = project.wiki.disk_path import_url = project.import_url.sub(/\.git\z/, ".git/wiki") - gitlab_shell.import_repository(project.repository_storage_path, disk_path, import_url) + gitlab_shell.import_repository(project.repository_storage, disk_path, import_url) rescue StandardError => e errors << { type: :wiki, errors: e.message } end diff --git a/lib/gitlab/ci/build/policy/kubernetes.rb b/lib/gitlab/ci/build/policy/kubernetes.rb index b20d374288f..782f6c4c0af 100644 --- a/lib/gitlab/ci/build/policy/kubernetes.rb +++ b/lib/gitlab/ci/build/policy/kubernetes.rb @@ -9,7 +9,7 @@ module Gitlab end end - def satisfied_by?(pipeline) + def satisfied_by?(pipeline, seed = nil) pipeline.has_kubernetes_active? end end diff --git a/lib/gitlab/ci/build/policy/refs.rb b/lib/gitlab/ci/build/policy/refs.rb index eadc0948d2f..4aa5dc89f47 100644 --- a/lib/gitlab/ci/build/policy/refs.rb +++ b/lib/gitlab/ci/build/policy/refs.rb @@ -7,7 +7,7 @@ module Gitlab @patterns = Array(refs) end - def satisfied_by?(pipeline) + def satisfied_by?(pipeline, seed = nil) @patterns.any? do |pattern| pattern, path = pattern.split('@', 2) diff --git a/lib/gitlab/ci/build/policy/specification.rb b/lib/gitlab/ci/build/policy/specification.rb index c317291f29d..f09ba42c074 100644 --- a/lib/gitlab/ci/build/policy/specification.rb +++ b/lib/gitlab/ci/build/policy/specification.rb @@ -15,7 +15,7 @@ module Gitlab @spec = spec end - def satisfied_by?(pipeline) + def satisfied_by?(pipeline, seed = nil) raise NotImplementedError end end diff --git a/lib/gitlab/ci/build/policy/variables.rb b/lib/gitlab/ci/build/policy/variables.rb new file mode 100644 index 00000000000..9d2a362b7d4 --- /dev/null +++ b/lib/gitlab/ci/build/policy/variables.rb @@ -0,0 +1,24 @@ +module Gitlab + module Ci + module Build + module Policy + class Variables < Policy::Specification + def initialize(expressions) + @expressions = Array(expressions) + end + + def satisfied_by?(pipeline, seed) + variables = seed.to_resource.scoped_variables_hash + + statements = @expressions.map do |statement| + ::Gitlab::Ci::Pipeline::Expression::Statement + .new(statement, variables) + end + + statements.any?(&:truthful?) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/build/step.rb b/lib/gitlab/ci/build/step.rb index 411f67f8ce7..0b1ebe4e048 100644 --- a/lib/gitlab/ci/build/step.rb +++ b/lib/gitlab/ci/build/step.rb @@ -14,7 +14,7 @@ module Gitlab self.new(:script).tap do |step| step.script = job.options[:before_script].to_a + job.options[:script].to_a step.script = job.commands.split("\n") if step.script.empty? - step.timeout = job.timeout + step.timeout = job.metadata_timeout step.when = WHEN_ON_SUCCESS end end @@ -25,7 +25,7 @@ module Gitlab self.new(:after_script).tap do |step| step.script = after_script - step.timeout = job.timeout + step.timeout = job.metadata_timeout step.when = WHEN_ALWAYS step.allow_failure = true end diff --git a/lib/gitlab/ci/config/entry/policy.rb b/lib/gitlab/ci/config/entry/policy.rb index 0027e9ec8c5..09e8e52b60f 100644 --- a/lib/gitlab/ci/config/entry/policy.rb +++ b/lib/gitlab/ci/config/entry/policy.rb @@ -25,15 +25,31 @@ module Gitlab include Entry::Validatable include Entry::Attributable - attributes :refs, :kubernetes + attributes :refs, :kubernetes, :variables validations do validates :config, presence: true - validates :config, allowed_keys: %i[refs kubernetes] + validates :config, allowed_keys: %i[refs kubernetes variables] + validate :variables_expressions_syntax with_options allow_nil: true do validates :refs, array_of_strings_or_regexps: true validates :kubernetes, allowed_values: %w[active] + validates :variables, array_of_strings: true + end + + def variables_expressions_syntax + return unless variables.is_a?(Array) + + statements = variables.map do |statement| + ::Gitlab::Ci::Pipeline::Expression::Statement.new(statement) + end + + statements.each do |statement| + unless statement.valid? + errors.add(:variables, "Invalid expression syntax") + end + end end end end diff --git a/lib/gitlab/ci/pipeline/chain/populate.rb b/lib/gitlab/ci/pipeline/chain/populate.rb index b2b00c8cb4b..d299a5677de 100644 --- a/lib/gitlab/ci/pipeline/chain/populate.rb +++ b/lib/gitlab/ci/pipeline/chain/populate.rb @@ -17,8 +17,6 @@ module Gitlab # Populate pipeline with all stages and builds from pipeline seeds. # pipeline.stage_seeds.each do |stage| - stage.user = current_user - pipeline.stages << stage.to_resource stage.seeds.each do |build| diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/string.rb b/lib/gitlab/ci/pipeline/expression/lexeme/string.rb index 48bde213d44..346c92dc51e 100644 --- a/lib/gitlab/ci/pipeline/expression/lexeme/string.rb +++ b/lib/gitlab/ci/pipeline/expression/lexeme/string.rb @@ -4,7 +4,7 @@ module Gitlab module Expression module Lexeme class String < Lexeme::Value - PATTERN = /("(?<string>.+?)")|('(?<string>.+?)')/.freeze + PATTERN = /("(?<string>.*?)")|('(?<string>.*?)')/.freeze def initialize(value) @value = value diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/variable.rb b/lib/gitlab/ci/pipeline/expression/lexeme/variable.rb index b781c15fd67..37643c8ef53 100644 --- a/lib/gitlab/ci/pipeline/expression/lexeme/variable.rb +++ b/lib/gitlab/ci/pipeline/expression/lexeme/variable.rb @@ -11,7 +11,7 @@ module Gitlab end def evaluate(variables = {}) - HashWithIndifferentAccess.new(variables).fetch(@name, nil) + variables.with_indifferent_access.fetch(@name, nil) end def self.build(string) diff --git a/lib/gitlab/ci/pipeline/expression/statement.rb b/lib/gitlab/ci/pipeline/expression/statement.rb index 4f0e101b730..09a7c98464b 100644 --- a/lib/gitlab/ci/pipeline/expression/statement.rb +++ b/lib/gitlab/ci/pipeline/expression/statement.rb @@ -14,12 +14,9 @@ module Gitlab %w[variable] ].freeze - def initialize(statement, pipeline) + def initialize(statement, variables = {}) @lexer = Expression::Lexer.new(statement) - - @variables = pipeline.variables.map do |variable| - [variable.key, variable.value] - end + @variables = variables.with_indifferent_access end def parse_tree @@ -35,6 +32,16 @@ module Gitlab def evaluate parse_tree.evaluate(@variables.to_h) end + + def truthful? + evaluate.present? + end + + def valid? + parse_tree.is_a?(Lexeme::Base) + rescue StatementError + false + end end end end diff --git a/lib/gitlab/ci/pipeline/seed/build.rb b/lib/gitlab/ci/pipeline/seed/build.rb index 7cd7c864448..6980b0b7aff 100644 --- a/lib/gitlab/ci/pipeline/seed/build.rb +++ b/lib/gitlab/ci/pipeline/seed/build.rb @@ -11,21 +11,16 @@ module Gitlab @pipeline = pipeline @attributes = attributes - @only = attributes.delete(:only) - @except = attributes.delete(:except) - end - - def user=(current_user) - @attributes.merge!(user: current_user) + @only = Gitlab::Ci::Build::Policy + .fabricate(attributes.delete(:only)) + @except = Gitlab::Ci::Build::Policy + .fabricate(attributes.delete(:except)) end def included? strong_memoize(:inclusion) do - only_specs = Gitlab::Ci::Build::Policy.fabricate(@only) - except_specs = Gitlab::Ci::Build::Policy.fabricate(@except) - - only_specs.all? { |spec| spec.satisfied_by?(@pipeline) } && - except_specs.none? { |spec| spec.satisfied_by?(@pipeline) } + @only.all? { |spec| spec.satisfied_by?(@pipeline, self) } && + @except.none? { |spec| spec.satisfied_by?(@pipeline, self) } end end @@ -33,6 +28,7 @@ module Gitlab @attributes.merge( pipeline: @pipeline, project: @pipeline.project, + user: @pipeline.user, ref: @pipeline.ref, tag: @pipeline.tag, trigger_request: @pipeline.legacy_trigger, diff --git a/lib/gitlab/ci/pipeline/seed/stage.rb b/lib/gitlab/ci/pipeline/seed/stage.rb index 1fcbdc1b15a..c101f30d6e8 100644 --- a/lib/gitlab/ci/pipeline/seed/stage.rb +++ b/lib/gitlab/ci/pipeline/seed/stage.rb @@ -17,10 +17,6 @@ module Gitlab end end - def user=(current_user) - @builds.each { |seed| seed.user = current_user } - end - def attributes { name: @attributes.fetch(:name), pipeline: @pipeline, diff --git a/lib/gitlab/ci/variables/collection.rb b/lib/gitlab/ci/variables/collection.rb index 0deca55fe8f..ad30b3f427c 100644 --- a/lib/gitlab/ci/variables/collection.rb +++ b/lib/gitlab/ci/variables/collection.rb @@ -30,7 +30,13 @@ module Gitlab end def to_runner_variables - self.map(&:to_hash) + self.map(&:to_runner_variable) + end + + def to_hash + self.to_runner_variables + .map { |env| [env.fetch(:key), env.fetch(:value)] } + .to_h.with_indifferent_access end end end diff --git a/lib/gitlab/ci/variables/collection/item.rb b/lib/gitlab/ci/variables/collection/item.rb index 939912981e6..23ed71db8b0 100644 --- a/lib/gitlab/ci/variables/collection/item.rb +++ b/lib/gitlab/ci/variables/collection/item.rb @@ -17,7 +17,7 @@ module Gitlab end def ==(other) - to_hash == self.class.fabricate(other).to_hash + to_runner_variable == self.class.fabricate(other).to_runner_variable end ## @@ -25,7 +25,7 @@ module Gitlab # don't expose `file` attribute at all (stems from what the runner # expects). # - def to_hash + def to_runner_variable @variable.reject do |hash_key, hash_value| hash_key == :file && hash_value == false end diff --git a/lib/gitlab/ee_compat_check.rb b/lib/gitlab/ee_compat_check.rb index 0fb71976883..5fdd5dcd374 100644 --- a/lib/gitlab/ee_compat_check.rb +++ b/lib/gitlab/ee_compat_check.rb @@ -2,8 +2,8 @@ module Gitlab # Checks if a set of migrations requires downtime or not. class EeCompatCheck - DEFAULT_CE_PROJECT_URL = 'https://gitlab.com/gitlab-org/gitlab-ce'.freeze - EE_REPO_URL = 'https://gitlab.com/gitlab-org/gitlab-ee.git'.freeze + CANONICAL_CE_PROJECT_URL = 'https://gitlab.com/gitlab-org/gitlab-ce'.freeze + CANONICAL_EE_REPO_URL = 'https://gitlab.com/gitlab-org/gitlab-ee.git'.freeze CHECK_DIR = Rails.root.join('ee_compat_check') IGNORED_FILES_REGEX = %r{VERSION|CHANGELOG\.md|db/schema\.rb}i.freeze PLEASE_READ_THIS_BANNER = %Q{ @@ -11,57 +11,81 @@ module Gitlab ===================== PLEASE READ THIS ===================== ============================================================ }.freeze + STAY_STRONG_LINK_TO_DOCS = %Q{ + Stay 💪! For more information, see + https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html + }.freeze THANKS_FOR_READING_BANNER = %Q{ ============================================================ ==================== THANKS FOR READING ==================== ============================================================\n }.freeze - attr_reader :ee_repo_dir, :patches_dir, :ce_project_url, :ce_repo_url, :ce_branch, :ee_branch_found + attr_reader :ee_repo_dir, :patches_dir + attr_reader :ce_project_url, :ee_repo_url + attr_reader :ce_branch, :ee_remote_with_branch, :ee_branch_found attr_reader :job_id, :failed_files - def initialize(branch:, ce_project_url: DEFAULT_CE_PROJECT_URL, job_id: nil) + def initialize(branch:, ce_project_url: CANONICAL_CE_PROJECT_URL, job_id: nil) @ee_repo_dir = CHECK_DIR.join('ee-repo') @patches_dir = CHECK_DIR.join('patches') @ce_branch = branch @ce_project_url = ce_project_url - @ce_repo_url = "#{ce_project_url}.git" + @ee_repo_url = ce_public_repo_url.sub('gitlab-ce', 'gitlab-ee') @job_id = job_id end def check ensure_patches_dir - add_remote('canonical-ce', "#{DEFAULT_CE_PROJECT_URL}.git") - generate_patch(branch: ce_branch, patch_path: ce_patch_full_path, remote: 'canonical-ce') + # We're generating the patch against the canonical-ce remote since forks' + # master branch are not necessarily up-to-date. + add_remote('canonical-ce', "#{CANONICAL_CE_PROJECT_URL}.git") + generate_patch(branch: ce_branch, patch_path: ce_patch_full_path, branch_remote: 'origin', master_remote: 'canonical-ce') ensure_ee_repo Dir.chdir(ee_repo_dir) do step("In the #{ee_repo_dir} directory") - add_remote('canonical-ee', EE_REPO_URL) + ee_remotes.each do |key, url| + add_remote(key, url) + end + fetch(branch: 'master', depth: 20, remote: 'canonical-ee') status = catch(:halt_check) do ce_branch_compat_check! delete_ee_branches_locally! ee_branch_presence_check! - step("Checking out #{ee_branch_found}", %W[git checkout -b #{ee_branch_found} canonical-ee/#{ee_branch_found}]) - generate_patch(branch: ee_branch_found, patch_path: ee_patch_full_path, remote: 'canonical-ee') + step("Checking out #{ee_remote_with_branch}/#{ee_branch_found}", %W[git checkout -b #{ee_branch_found} #{ee_remote_with_branch}/#{ee_branch_found}]) + generate_patch(branch: ee_branch_found, patch_path: ee_patch_full_path, branch_remote: ee_remote_with_branch, master_remote: 'canonical-ee') ee_branch_compat_check! end delete_ee_branches_locally! - if status.nil? - true - else - false - end + status.nil? end end private + def fork? + ce_project_url != CANONICAL_CE_PROJECT_URL + end + + def ee_remotes + return @ee_remotes if defined?(@ee_remotes) + + remotes = + { + 'ee' => ee_repo_url, + 'canonical-ee' => CANONICAL_EE_REPO_URL + } + remotes.delete('ee') unless fork? + + @ee_remotes = remotes + end + def add_remote(name, url) step( "Adding the #{name} remote (#{url})", @@ -70,28 +94,32 @@ module Gitlab end def ensure_ee_repo - if Dir.exist?(ee_repo_dir) - step("#{ee_repo_dir} already exists") - else - step( - "Cloning #{EE_REPO_URL} into #{ee_repo_dir}", - %W[git clone --branch master --single-branch --depth=200 #{EE_REPO_URL} #{ee_repo_dir}] - ) + unless clone_repo(ee_repo_url, ee_repo_dir) + # Fallback to using the canonical EE if there is no forked EE + clone_repo(CANONICAL_EE_REPO_URL, ee_repo_dir) end end + def clone_repo(url, dir) + _, status = step( + "Cloning #{url} into #{dir}", + %W[git clone --branch master --single-branch --depth=200 #{url} #{dir}] + ) + status.zero? + end + def ensure_patches_dir FileUtils.mkdir_p(patches_dir) end - def generate_patch(branch:, patch_path:, remote:) + def generate_patch(branch:, patch_path:, branch_remote:, master_remote:) FileUtils.rm(patch_path, force: true) - find_merge_base_with_master(branch: branch, master_remote: remote) + find_merge_base_with_master(branch: branch, branch_remote: branch_remote, master_remote: master_remote) step( - "Generating the patch against #{remote}/master in #{patch_path}", - %W[git diff --binary #{remote}/master...origin/#{branch}] + "Generating the patch against #{master_remote}/master in #{patch_path}", + %W[git diff --binary #{master_remote}/master...#{branch_remote}/#{branch}] ) do |output, status| throw(:halt_check, :ko) unless status.zero? @@ -109,23 +137,22 @@ module Gitlab end def ee_branch_presence_check! - _, status = step("Fetching origin/#{ee_branch_prefix}", %W[git fetch canonical-ee #{ee_branch_prefix}]) - - if status.zero? - @ee_branch_found = ee_branch_prefix - return + ee_remotes.keys.each do |remote| + [ee_branch_prefix, ee_branch_suffix].each do |branch| + _, status = step("Fetching #{remote}/#{ee_branch_prefix}", %W[git fetch #{remote} #{branch}]) + + if status.zero? + @ee_remote_with_branch = remote + @ee_branch_found = branch + return true + end + end end - _, status = step("Fetching origin/#{ee_branch_suffix}", %W[git fetch canonical-ee #{ee_branch_suffix}]) - - if status.zero? - @ee_branch_found = ee_branch_suffix - else - puts - puts ce_branch_doesnt_apply_cleanly_and_no_ee_branch_msg + puts + puts ce_branch_doesnt_apply_cleanly_and_no_ee_branch_msg - throw(:halt_check, :ko) - end + throw(:halt_check, :ko) end def ee_branch_compat_check! @@ -181,10 +208,10 @@ module Gitlab command(%W[git branch --delete --force #{ee_branch_suffix}]) end - def merge_base_found?(master_remote:, branch:) + def merge_base_found?(branch:, branch_remote:, master_remote:) step( "Finding merge base with #{master_remote}/master", - %W[git merge-base #{master_remote}/master origin/#{branch}] + %W[git merge-base #{master_remote}/master #{branch_remote}/#{branch}] ) do |output, status| if status.zero? puts "Merge base was found: #{output}" @@ -193,7 +220,7 @@ module Gitlab end end - def find_merge_base_with_master(branch:, master_remote:) + def find_merge_base_with_master(branch:, branch_remote:, master_remote:) # Start with (Math.exp(3).to_i = 20) until (Math.exp(6).to_i = 403) # In total we go (20 + 54 + 148 + 403 = 625) commits deeper depth = 20 @@ -202,10 +229,10 @@ module Gitlab depth += Math.exp(factor).to_i # Repository is initially cloned with a depth of 20 so we need to fetch # deeper in the case the branch has more than 20 commits on top of master - fetch(branch: branch, depth: depth, remote: 'origin') + fetch(branch: branch, depth: depth, remote: branch_remote) fetch(branch: 'master', depth: depth, remote: master_remote) - merge_base_found?(master_remote: master_remote, branch: branch) + merge_base_found?(branch: branch, branch_remote: branch_remote, master_remote: master_remote) end raise "\n#{branch} is too far behind #{master_remote}/master, please rebase it!\n" unless success @@ -274,6 +301,13 @@ module Gitlab Gitlab::Popen.popen(cmd) end + # We're "re-creating" the repo URL because ENV['CI_REPOSITORY_URL'] contains + # redacted credentials (e.g. "***:****") which are useless in instructions + # the job gives. + def ce_public_repo_url + "#{ce_project_url}.git" + end + def applies_cleanly_msg(branch) %Q{ #{PLEASE_READ_THIS_BANNER} @@ -288,13 +322,15 @@ module Gitlab end def ce_branch_doesnt_apply_cleanly_and_no_ee_branch_msg + ee_repos = ee_remotes.values.uniq + %Q{ #{PLEASE_READ_THIS_BANNER} 💥 Oh no! 💥 The `#{ce_branch}` branch does not apply cleanly to the current EE/master, and no `#{ee_branch_prefix}` or `#{ee_branch_suffix}` branch - was found in the EE repository. + was found in #{ee_repos.join(' nor in ')}. If you're a community contributor, don't worry, someone from GitLab Inc. will take care of this, and you don't have to do anything. @@ -314,17 +350,17 @@ module Gitlab 1. Create a new branch from master and cherry-pick your CE commits # In the EE repo - $ git fetch #{EE_REPO_URL} master + $ git fetch #{CANONICAL_EE_REPO_URL} master $ git checkout -b #{ee_branch_prefix} FETCH_HEAD - $ git fetch #{ce_repo_url} #{ce_branch} + $ git fetch #{ce_public_repo_url} #{ce_branch} $ git cherry-pick SHA # Repeat for all the commits you want to pick - You can squash the `#{ce_branch}` commits into a single "Port of #{ce_branch} to EE" commit. + Note: You can squash the `#{ce_branch}` commits into a single "Port of #{ce_branch} to EE" commit. 2. Apply your branch's patch to EE # In the EE repo - $ git fetch #{EE_REPO_URL} master + $ git fetch #{CANONICAL_EE_REPO_URL} master $ git checkout -b #{ee_branch_prefix} FETCH_HEAD $ wget #{patch_url} && git apply --3way #{ce_patch_name} @@ -356,10 +392,9 @@ module Gitlab ⚠️ Also, don't forget to create a new merge request on gitlab-ee and cross-link it with the CE merge request. - Once this is done, you can retry this failed build, and it should pass. + Once this is done, you can retry this failed job, and it should pass. - Stay 💪 ! For more information, see - https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html + #{STAY_STRONG_LINK_TO_DOCS} #{THANKS_FOR_READING_BANNER} } end @@ -371,16 +406,15 @@ module Gitlab The `#{ce_branch}` does not apply cleanly to the current EE/master, and even though a `#{ee_branch_found}` branch - exists in the EE repository, it does not apply cleanly either to + exists in #{ee_repo_url}, it does not apply cleanly either to EE/master! #{conflicting_files_msg} Please update the `#{ee_branch_found}`, push it again to gitlab-ee, and - retry this build. + retry this job. - Stay 💪 ! For more information, see - https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html + #{STAY_STRONG_LINK_TO_DOCS} #{THANKS_FOR_READING_BANNER} } end diff --git a/lib/gitlab/git/checksum.rb b/lib/gitlab/git/checksum.rb new file mode 100644 index 00000000000..3ef0f0a8854 --- /dev/null +++ b/lib/gitlab/git/checksum.rb @@ -0,0 +1,82 @@ +module Gitlab + module Git + class Checksum + include Gitlab::Git::Popen + + EMPTY_REPOSITORY_CHECKSUM = '0000000000000000000000000000000000000000'.freeze + + Failure = Class.new(StandardError) + + attr_reader :path, :relative_path, :storage, :storage_path + + def initialize(storage, relative_path) + @storage = storage + @storage_path = Gitlab.config.repositories.storages[storage].legacy_disk_path + @relative_path = "#{relative_path}.git" + @path = File.join(storage_path, @relative_path) + end + + def calculate + unless repository_exists? + failure!(Gitlab::Git::Repository::NoRepository, 'No repository for such path') + end + + calculate_checksum_by_shelling_out + end + + private + + def repository_exists? + raw_repository.exists? + end + + def calculate_checksum_by_shelling_out + args = %W(--git-dir=#{path} show-ref --heads --tags) + output, status = run_git(args) + + if status&.zero? + refs = output.split("\n") + + result = refs.inject(nil) do |checksum, ref| + value = Digest::SHA1.hexdigest(ref).hex + + if checksum.nil? + value + else + checksum ^ value + end + end + + result.to_s(16) + else + # Empty repositories return with a non-zero status and an empty output. + if output&.empty? + EMPTY_REPOSITORY_CHECKSUM + else + failure!(Gitlab::Git::Checksum::Failure, output) + end + end + end + + def failure!(klass, message) + Gitlab::GitLogger.error("'git show-ref --heads --tags' in #{path}: #{message}") + + raise klass.new("Could not calculate the checksum for #{path}: #{message}") + end + + def circuit_breaker + @circuit_breaker ||= Gitlab::Git::Storage::CircuitBreaker.for_storage(storage) + end + + def raw_repository + Gitlab::Git::Repository.new(storage, relative_path, nil) + end + + def run_git(args) + circuit_breaker.perform do + popen([Gitlab.config.git.bin_path, *args], path) + end + end + end + end +end diff --git a/lib/gitlab/git/gitlab_projects.rb b/lib/gitlab/git/gitlab_projects.rb index dc0bc8518bc..099709620b3 100644 --- a/lib/gitlab/git/gitlab_projects.rb +++ b/lib/gitlab/git/gitlab_projects.rb @@ -4,20 +4,14 @@ module Gitlab include Gitlab::Git::Popen include Gitlab::Utils::StrongMemoize - ShardNameNotFoundError = Class.new(StandardError) - - # Absolute path to directory where repositories are stored. - # Example: /home/git/repositories - attr_reader :shard_path + # Name of shard where repositories are stored. + # Example: nfs-file06 + attr_reader :shard_name # Relative path is a directory name for repository with .git at the end. # Example: gitlab-org/gitlab-test.git attr_reader :repository_relative_path - # Absolute path to the repository. - # Example: /home/git/repositorities/gitlab-org/gitlab-test.git - attr_reader :repository_absolute_path - # This is the path at which the gitlab-shell hooks directory can be found. # It's essential for integration between git and GitLab proper. All new # repositories should have their hooks directory symlinked here. @@ -25,13 +19,12 @@ module Gitlab attr_reader :logger - def initialize(shard_path, repository_relative_path, global_hooks_path:, logger:) - @shard_path = shard_path + def initialize(shard_name, repository_relative_path, global_hooks_path:, logger:) + @shard_name = shard_name @repository_relative_path = repository_relative_path @logger = logger @global_hooks_path = global_hooks_path - @repository_absolute_path = File.join(shard_path, repository_relative_path) @output = StringIO.new end @@ -41,6 +34,22 @@ module Gitlab io.read end + # Absolute path to the repository. + # Example: /home/git/repositorities/gitlab-org/gitlab-test.git + # Probably will be removed when we fully migrate to Gitaly, part of + # https://gitlab.com/gitlab-org/gitaly/issues/1124. + def repository_absolute_path + strong_memoize(:repository_absolute_path) do + File.join(shard_path, repository_relative_path) + end + end + + def shard_path + strong_memoize(:shard_path) do + Gitlab.config.repositories.storages.fetch(shard_name).legacy_disk_path + end + end + # Import project via git clone --bare # URL must be publicly cloneable def import_project(source, timeout) @@ -53,12 +62,12 @@ module Gitlab end end - def fork_repository(new_shard_path, new_repository_relative_path) + def fork_repository(new_shard_name, new_repository_relative_path) Gitlab::GitalyClient.migrate(:fork_repository) do |is_enabled| if is_enabled - gitaly_fork_repository(new_shard_path, new_repository_relative_path) + gitaly_fork_repository(new_shard_name, new_repository_relative_path) else - git_fork_repository(new_shard_path, new_repository_relative_path) + git_fork_repository(new_shard_name, new_repository_relative_path) end end end @@ -205,17 +214,6 @@ module Gitlab private - def shard_name - strong_memoize(:shard_name) do - shard_name_from_shard_path(shard_path) - end - end - - def shard_name_from_shard_path(shard_path) - Gitlab.config.repositories.storages.find { |_, info| info.legacy_disk_path == shard_path }&.first || - raise(ShardNameNotFoundError, "no shard found for path '#{shard_path}'") - end - def git_import_repository(source, timeout) # Skip import if repo already exists return false if File.exist?(repository_absolute_path) @@ -252,8 +250,9 @@ module Gitlab false end - def git_fork_repository(new_shard_path, new_repository_relative_path) + def git_fork_repository(new_shard_name, new_repository_relative_path) from_path = repository_absolute_path + new_shard_path = Gitlab.config.repositories.storages.fetch(new_shard_name).legacy_disk_path to_path = File.join(new_shard_path, new_repository_relative_path) # The repository cannot already exist @@ -271,8 +270,8 @@ module Gitlab run(cmd, nil) && Gitlab::Git::Repository.create_hooks(to_path, global_hooks_path) end - def gitaly_fork_repository(new_shard_path, new_repository_relative_path) - target_repository = Gitlab::Git::Repository.new(shard_name_from_shard_path(new_shard_path), new_repository_relative_path, nil) + def gitaly_fork_repository(new_shard_name, new_repository_relative_path) + target_repository = Gitlab::Git::Repository.new(new_shard_name, new_repository_relative_path, nil) raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil) Gitlab::GitalyClient::RepositoryService.new(target_repository).fork_repository(raw_repository) diff --git a/lib/gitlab/git/gitmodules_parser.rb b/lib/gitlab/git/gitmodules_parser.rb index 4a43b9b444d..4b505312f60 100644 --- a/lib/gitlab/git/gitmodules_parser.rb +++ b/lib/gitlab/git/gitmodules_parser.rb @@ -46,6 +46,8 @@ module Gitlab iterator = State.new @content.split("\n").each_with_object(iterator) do |text, iterator| + text.chomp! + next if text =~ /^\s*#/ if text =~ /\A\[submodule "(?<name>[^"]+)"\]\z/ @@ -55,7 +57,7 @@ module Gitlab next unless text =~ /\A\s*(?<key>\w+)\s*=\s*(?<value>.*)\z/ - value = $~[:value].chomp + value = $~[:value] iterator.set_attribute($~[:key], value) end end diff --git a/lib/gitlab/git/env.rb b/lib/gitlab/git/hook_env.rb index 9d0b47a1a6d..455e8451c10 100644 --- a/lib/gitlab/git/env.rb +++ b/lib/gitlab/git/hook_env.rb @@ -3,37 +3,39 @@ module Gitlab module Git # Ephemeral (per request) storage for environment variables that some Git - # commands may need. + # commands need during internal API calls made from Git push hooks. # # For example, in pre-receive hooks, new objects are put in a temporary # $GIT_OBJECT_DIRECTORY. Without it set, the new objects cannot be retrieved # (this would break push rules for instance). # # This class is thread-safe via RequestStore. - class Env + class HookEnv WHITELISTED_VARIABLES = %w[ - GIT_OBJECT_DIRECTORY GIT_OBJECT_DIRECTORY_RELATIVE - GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE ].freeze - def self.set(env) + def self.set(gl_repository, env) return unless RequestStore.active? - RequestStore.store[:gitlab_git_env] = whitelist_git_env(env) + raise "missing gl_repository" if gl_repository.blank? + + RequestStore.store[:gitlab_git_env] ||= {} + RequestStore.store[:gitlab_git_env][gl_repository] = whitelist_git_env(env) end - def self.all + def self.all(gl_repository) return {} unless RequestStore.active? - RequestStore.fetch(:gitlab_git_env) { {} } + h = RequestStore.fetch(:gitlab_git_env) { {} } + h.fetch(gl_repository, {}) end - def self.to_env_hash + def self.to_env_hash(gl_repository) env = {} - all.compact.each do |key, value| + all(gl_repository).compact.each do |key, value| value = value.join(File::PATH_SEPARATOR) if value.is_a?(Array) env[key.to_s] = value end @@ -41,10 +43,6 @@ module Gitlab env end - def self.[](key) - all[key] - end - def self.whitelist_git_env(env) env.select { |key, _| WHITELISTED_VARIABLES.include?(key.to_s) }.with_indifferent_access end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 2d16a81c888..d16a096ffb9 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -96,7 +96,7 @@ module Gitlab storage_path = Gitlab.config.repositories.storages[@storage].legacy_disk_path @gitlab_projects = Gitlab::Git::GitlabProjects.new( - storage_path, + storage, relative_path, global_hooks_path: Gitlab.config.gitlab_shell.hooks_path, logger: Rails.logger @@ -1745,21 +1745,11 @@ module Gitlab end def alternate_object_directories - relative_paths = relative_object_directories - - if relative_paths.any? - relative_paths.map { |d| File.join(path, d) } - else - absolute_object_directories.flat_map { |d| d.split(File::PATH_SEPARATOR) } - end + relative_object_directories.map { |d| File.join(path, d) } end def relative_object_directories - Gitlab::Git::Env.all.values_at(*ALLOWED_OBJECT_RELATIVE_DIRECTORIES_VARIABLES).flatten.compact - end - - def absolute_object_directories - Gitlab::Git::Env.all.values_at(*ALLOWED_OBJECT_DIRECTORIES_VARIABLES).flatten.compact + Gitlab::Git::HookEnv.all(gl_repository).values_at(*ALLOWED_OBJECT_RELATIVE_DIRECTORIES_VARIABLES).flatten.compact end # Get the content of a blob for a given commit. If the blob is a commit diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 0a2a23e835b..ed0644f6cf1 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -99,8 +99,6 @@ module Gitlab end def check_active_user! - return if deploy_key? - if user && !user_access.allowed? raise UnauthorizedError, ERROR_MESSAGES[:account_blocked] end @@ -215,7 +213,7 @@ module Gitlab raise UnauthorizedError, ERROR_MESSAGES[:read_only] end - if deploy_key + if deploy_key? unless deploy_key.can_push_to?(project) raise UnauthorizedError, ERROR_MESSAGES[:deploy_key_upload] end @@ -305,8 +303,10 @@ module Gitlab case actor when User actor + when DeployKey + nil when Key - actor.user unless actor.is_a?(DeployKey) + actor.user when :ci nil end diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb index a8c6d478de8..405567db94a 100644 --- a/lib/gitlab/gitaly_client/util.rb +++ b/lib/gitlab/gitaly_client/util.rb @@ -3,11 +3,9 @@ module Gitlab module Util class << self def repository(repository_storage, relative_path, gl_repository) - git_object_directory = Gitlab::Git::Env['GIT_OBJECT_DIRECTORY_RELATIVE'].presence || - Gitlab::Git::Env['GIT_OBJECT_DIRECTORY'].presence - git_alternate_object_directories = - Array.wrap(Gitlab::Git::Env['GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE']).presence || - Array.wrap(Gitlab::Git::Env['GIT_ALTERNATE_OBJECT_DIRECTORIES']).flat_map { |d| d.split(File::PATH_SEPARATOR) } + git_env = Gitlab::Git::HookEnv.all(gl_repository) + git_object_directory = git_env['GIT_OBJECT_DIRECTORY_RELATIVE'].presence + git_alternate_object_directories = Array.wrap(git_env['GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE']) Gitaly::Repository.new( storage_name: repository_storage, diff --git a/lib/gitlab/github_import/importer/repository_importer.rb b/lib/gitlab/github_import/importer/repository_importer.rb index b1b283e98b5..01168abde6c 100644 --- a/lib/gitlab/github_import/importer/repository_importer.rb +++ b/lib/gitlab/github_import/importer/repository_importer.rb @@ -56,9 +56,8 @@ module Gitlab def import_wiki_repository wiki_path = "#{project.disk_path}.wiki" - storage_path = project.repository_storage_path - gitlab_shell.import_repository(storage_path, wiki_path, wiki_url) + gitlab_shell.import_repository(project.repository_storage, wiki_path, wiki_url) true rescue Gitlab::Shell::Error => e diff --git a/lib/gitlab/http.rb b/lib/gitlab/http.rb index 96558872a37..9aca3b0fb26 100644 --- a/lib/gitlab/http.rb +++ b/lib/gitlab/http.rb @@ -4,6 +4,8 @@ # calling internal IP or services. module Gitlab class HTTP + BlockedUrlError = Class.new(StandardError) + include HTTParty # rubocop:disable Gitlab/HTTParty connection_adapter ProxyHTTPConnectionAdapter diff --git a/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb b/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb new file mode 100644 index 00000000000..aef371d81eb --- /dev/null +++ b/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb @@ -0,0 +1,83 @@ +module Gitlab + module ImportExport + module AfterExportStrategies + class BaseAfterExportStrategy + include ActiveModel::Validations + extend Forwardable + + StrategyError = Class.new(StandardError) + + AFTER_EXPORT_LOCK_FILE_NAME = '.after_export_action'.freeze + + private + + attr_reader :project, :current_user + + public + + def initialize(attributes = {}) + @options = OpenStruct.new(attributes) + + self.class.instance_eval do + def_delegators :@options, *attributes.keys + end + end + + def execute(current_user, project) + return unless project&.export_project_path + + @project = project + @current_user = current_user + + if invalid? + log_validation_errors + + return + end + + create_or_update_after_export_lock + strategy_execute + + true + rescue => e + project.import_export_shared.error(e) + false + ensure + delete_after_export_lock + end + + def to_json(options = {}) + @options.to_h.merge!(klass: self.class.name).to_json + end + + def self.lock_file_path(project) + return unless project&.export_path + + File.join(project.export_path, AFTER_EXPORT_LOCK_FILE_NAME) + end + + protected + + def strategy_execute + raise NotImplementedError + end + + private + + def create_or_update_after_export_lock + FileUtils.touch(self.class.lock_file_path(project)) + end + + def delete_after_export_lock + lock_file = self.class.lock_file_path(project) + + FileUtils.rm(lock_file) if lock_file.present? && File.exist?(lock_file) + end + + def log_validation_errors + errors.full_messages.each { |msg| project.import_export_shared.add_error_message(msg) } + end + end + end + end +end diff --git a/lib/gitlab/import_export/after_export_strategies/download_notification_strategy.rb b/lib/gitlab/import_export/after_export_strategies/download_notification_strategy.rb new file mode 100644 index 00000000000..4371a7eff56 --- /dev/null +++ b/lib/gitlab/import_export/after_export_strategies/download_notification_strategy.rb @@ -0,0 +1,17 @@ +module Gitlab + module ImportExport + module AfterExportStrategies + class DownloadNotificationStrategy < BaseAfterExportStrategy + private + + def strategy_execute + notification_service.project_exported(project, current_user) + end + + def notification_service + @notification_service ||= NotificationService.new + end + end + end + end +end diff --git a/lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb b/lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb new file mode 100644 index 00000000000..938664a95a1 --- /dev/null +++ b/lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb @@ -0,0 +1,61 @@ +module Gitlab + module ImportExport + module AfterExportStrategies + class WebUploadStrategy < BaseAfterExportStrategy + PUT_METHOD = 'PUT'.freeze + POST_METHOD = 'POST'.freeze + INVALID_HTTP_METHOD = 'invalid. Only PUT and POST methods allowed.'.freeze + + validates :url, url: true + + validate do + unless [PUT_METHOD, POST_METHOD].include?(http_method.upcase) + errors.add(:http_method, INVALID_HTTP_METHOD) + end + end + + def initialize(url:, http_method: PUT_METHOD) + super + end + + protected + + def strategy_execute + handle_response_error(send_file) + + project.remove_exported_project_file + end + + def handle_response_error(response) + unless response.success? + error_code = response.dig('Error', 'Code') || response.code + error_message = response.dig('Error', 'Message') || response.message + + raise StrategyError.new("Error uploading the project. Code #{error_code}: #{error_message}") + end + end + + private + + def send_file + export_file = File.open(project.export_project_path) + + Gitlab::HTTP.public_send(http_method.downcase, url, send_file_options(export_file)) # rubocop:disable GitlabSecurity/PublicSend + ensure + export_file.close if export_file + end + + def send_file_options(export_file) + { + body_stream: export_file, + headers: headers + } + end + + def headers + { 'Content-Length' => File.size(project.export_project_path).to_s } + end + end + end + end +end diff --git a/lib/gitlab/import_export/after_export_strategy_builder.rb b/lib/gitlab/import_export/after_export_strategy_builder.rb new file mode 100644 index 00000000000..7eabcae2380 --- /dev/null +++ b/lib/gitlab/import_export/after_export_strategy_builder.rb @@ -0,0 +1,24 @@ +module Gitlab + module ImportExport + class AfterExportStrategyBuilder + StrategyNotFoundError = Class.new(StandardError) + + def self.build!(strategy_klass, attributes = {}) + return default_strategy.new unless strategy_klass + + attributes ||= {} + klass = strategy_klass.constantize rescue nil + + unless klass && klass < AfterExportStrategies::BaseAfterExportStrategy + raise StrategyNotFoundError.new("Strategy #{strategy_klass} not found") + end + + klass.new(**attributes.symbolize_keys) + end + + def self.default_strategy + AfterExportStrategies::DownloadNotificationStrategy + end + end + end +end diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 791a54e1b69..598832fb2df 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -19,7 +19,7 @@ module Gitlab custom_attributes: 'ProjectCustomAttribute', project_badges: 'Badge' }.freeze - USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id].freeze + USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id closed_by_id].freeze PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze diff --git a/lib/gitlab/import_export/shared.rb b/lib/gitlab/import_export/shared.rb index 3d3d998a6a3..6d7c36ce38b 100644 --- a/lib/gitlab/import_export/shared.rb +++ b/lib/gitlab/import_export/shared.rb @@ -22,7 +22,7 @@ module Gitlab def error(error) error_out(error.message, caller[0].dup) - @errors << error.message + add_error_message(error.message) # Debug: if error.backtrace @@ -32,6 +32,14 @@ module Gitlab end end + def add_error_message(error_message) + @errors << error_message + end + + def after_export_in_progress? + File.exist?(after_export_lock_file) + end + private def relative_path @@ -45,6 +53,10 @@ module Gitlab def error_out(message, caller) Rails.logger.error("Import/Export error raised on #{caller}: #{message}") end + + def after_export_lock_file + AfterExportStrategies::BaseAfterExportStrategy.lock_file_path(project) + end end end end diff --git a/lib/gitlab/legacy_github_import/importer.rb b/lib/gitlab/legacy_github_import/importer.rb index 0526ef9eb13..7edd0ad2033 100644 --- a/lib/gitlab/legacy_github_import/importer.rb +++ b/lib/gitlab/legacy_github_import/importer.rb @@ -259,7 +259,7 @@ module Gitlab def import_wiki unless project.wiki.repository_exists? wiki = WikiFormatter.new(project) - gitlab_shell.import_repository(project.repository_storage_path, wiki.disk_path, wiki.import_url) + gitlab_shell.import_repository(project.repository_storage, wiki.disk_path, wiki.import_url) end rescue Gitlab::Shell::Error => e # GitHub error message when the wiki repo has not been created, diff --git a/lib/gitlab/metrics/sidekiq_metrics_exporter.rb b/lib/gitlab/metrics/sidekiq_metrics_exporter.rb index db8bdde74b2..47b4af5d649 100644 --- a/lib/gitlab/metrics/sidekiq_metrics_exporter.rb +++ b/lib/gitlab/metrics/sidekiq_metrics_exporter.rb @@ -4,6 +4,8 @@ require 'prometheus/client/rack/exporter' module Gitlab module Metrics class SidekiqMetricsExporter < Daemon + LOG_FILENAME = File.join(Rails.root, 'log', 'sidekiq_exporter.log') + def enabled? Gitlab::Metrics.metrics_folder_present? && settings.enabled end @@ -17,7 +19,13 @@ module Gitlab attr_reader :server def start_working - @server = ::WEBrick::HTTPServer.new(Port: settings.port, BindAddress: settings.address) + logger = WEBrick::Log.new(LOG_FILENAME) + access_log = [ + [logger, WEBrick::AccessLog::COMBINED_LOG_FORMAT] + ] + + @server = ::WEBrick::HTTPServer.new(Port: settings.port, BindAddress: settings.address, + Logger: logger, AccessLog: access_log) server.mount "/", Rack::Handler::WEBrick, rack_app server.start end diff --git a/lib/gitlab/performance_bar.rb b/lib/gitlab/performance_bar.rb index 6c2b2036074..92a308a12dc 100644 --- a/lib/gitlab/performance_bar.rb +++ b/lib/gitlab/performance_bar.rb @@ -5,6 +5,7 @@ module Gitlab def self.enabled?(user = nil) return true if Rails.env.development? + return true if user&.admin? return false unless user && allowed_group_id allowed_user_ids.include?(user.id) diff --git a/lib/gitlab/proxy_http_connection_adapter.rb b/lib/gitlab/proxy_http_connection_adapter.rb index c70d6f4cd84..d682289b632 100644 --- a/lib/gitlab/proxy_http_connection_adapter.rb +++ b/lib/gitlab/proxy_http_connection_adapter.rb @@ -10,8 +10,12 @@ module Gitlab class ProxyHTTPConnectionAdapter < HTTParty::ConnectionAdapter def connection - if !allow_local_requests? && blocked_url? - raise URI::InvalidURIError + unless allow_local_requests? + begin + Gitlab::UrlBlocker.validate!(uri, allow_local_network: false) + rescue Gitlab::UrlBlocker::BlockedUrlError => e + raise Gitlab::HTTP::BlockedUrlError, "URL '#{uri}' is blocked: #{e.message}" + end end super @@ -19,10 +23,6 @@ module Gitlab private - def blocked_url? - Gitlab::UrlBlocker.blocked_url?(uri, allow_private_networks: false) - end - def allow_local_requests? options.fetch(:allow_local_requests, allow_settings_local_requests?) end diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb index c8c15b9684a..67407b651a5 100644 --- a/lib/gitlab/shell.rb +++ b/lib/gitlab/shell.rb @@ -93,12 +93,12 @@ module Gitlab # Import repository # - # storage - project's storage path + # storage - project's storage name # name - project disk path # url - URL to import from # # Ex. - # import_repository("/path/to/storage", "gitlab/gitlab-ci", "https://gitlab.com/gitlab-org/gitlab-test.git") + # import_repository("nfs-file06", "gitlab/gitlab-ci", "https://gitlab.com/gitlab-org/gitlab-test.git") # # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/874 def import_repository(storage, name, url) @@ -131,8 +131,7 @@ module Gitlab if is_enabled repository.gitaly_repository_client.fetch_remote(remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, timeout: git_timeout, prune: prune) else - storage_path = Gitlab.config.repositories.storages[repository.storage].legacy_disk_path - local_fetch_remote(storage_path, repository.relative_path, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune) + local_fetch_remote(repository.storage, repository.relative_path, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune) end end end @@ -156,13 +155,13 @@ module Gitlab end # Fork repository to new path - # forked_from_storage - forked-from project's storage path - # forked_from_disk_path - project disk path - # forked_to_storage - forked-to project's storage path - # forked_to_disk_path - forked project disk path + # forked_from_storage - forked-from project's storage name + # forked_from_disk_path - project disk relative path + # forked_to_storage - forked-to project's storage name + # forked_to_disk_path - forked project disk relative path # # Ex. - # fork_repository("/path/to/forked_from/storage", "gitlab/gitlab-ci", "/path/to/forked_to/storage", "new-namespace/gitlab-ci") + # fork_repository("nfs-file06", "gitlab/gitlab-ci", "nfs-file07", "new-namespace/gitlab-ci") # # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/817 def fork_repository(forked_from_storage, forked_from_disk_path, forked_to_storage, forked_to_disk_path) @@ -420,16 +419,16 @@ module Gitlab private - def gitlab_projects(shard_path, disk_path) + def gitlab_projects(shard_name, disk_path) Gitlab::Git::GitlabProjects.new( - shard_path, + shard_name, disk_path, global_hooks_path: Gitlab.config.gitlab_shell.hooks_path, logger: Rails.logger ) end - def local_fetch_remote(storage_path, repository_relative_path, remote, ssh_auth: nil, forced: false, no_tags: false, prune: true) + def local_fetch_remote(storage_name, repository_relative_path, remote, ssh_auth: nil, forced: false, no_tags: false, prune: true) vars = { force: forced, tags: !no_tags, prune: prune } if ssh_auth&.ssh_import? @@ -442,7 +441,7 @@ module Gitlab end end - cmd = gitlab_projects(storage_path, repository_relative_path) + cmd = gitlab_projects(storage_name, repository_relative_path) success = cmd.fetch_remote(remote, git_timeout, vars) diff --git a/lib/gitlab/url_blocker.rb b/lib/gitlab/url_blocker.rb index 0f9f939e204..db97f65bd54 100644 --- a/lib/gitlab/url_blocker.rb +++ b/lib/gitlab/url_blocker.rb @@ -2,48 +2,84 @@ require 'resolv' module Gitlab class UrlBlocker - class << self - def blocked_url?(url, allow_private_networks: true, valid_ports: []) - return false if url.nil? + BlockedUrlError = Class.new(StandardError) - blocked_ips = ["127.0.0.1", "::1", "0.0.0.0"] - blocked_ips.concat(Socket.ip_address_list.map(&:ip_address)) + class << self + def validate!(url, allow_localhost: false, allow_local_network: true, valid_ports: []) + return true if url.nil? begin uri = Addressable::URI.parse(url) - # Allow imports from the GitLab instance itself but only from the configured ports - return false if internal?(uri) + rescue Addressable::URI::InvalidURIError + raise BlockedUrlError, "URI is invalid" + end - return true if blocked_port?(uri.port, valid_ports) - return true if blocked_user_or_hostname?(uri.user) - return true if blocked_user_or_hostname?(uri.hostname) + # Allow imports from the GitLab instance itself but only from the configured ports + return true if internal?(uri) - addrs_info = Addrinfo.getaddrinfo(uri.hostname, 80, nil, :STREAM) - server_ips = addrs_info.map(&:ip_address) + port = uri.port || uri.default_port + validate_port!(port, valid_ports) if valid_ports.any? + validate_user!(uri.user) + validate_hostname!(uri.hostname) - return true if (blocked_ips & server_ips).any? - return true if !allow_private_networks && private_network?(addrs_info) - rescue Addressable::URI::InvalidURIError - return true + begin + addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM) rescue SocketError - return false + return true end + validate_localhost!(addrs_info) unless allow_localhost + validate_local_network!(addrs_info) unless allow_local_network + + true + end + + def blocked_url?(*args) + validate!(*args) + false + rescue BlockedUrlError + true end private - def blocked_port?(port, valid_ports) - return false if port.blank? || valid_ports.blank? + def validate_port!(port, valid_ports) + return if port.blank? + # Only ports under 1024 are restricted + return if port >= 1024 + return if valid_ports.include?(port) - port < 1024 && !valid_ports.include?(port) + raise BlockedUrlError, "Only allowed ports are #{valid_ports.join(', ')}, and any over 1024" end - def blocked_user_or_hostname?(value) - return false if value.blank? + def validate_user!(value) + return if value.blank? + return if value =~ /\A\p{Alnum}/ - value !~ /\A\p{Alnum}/ + raise BlockedUrlError, "Username needs to start with an alphanumeric character" + end + + def validate_hostname!(value) + return if value.blank? + return if value =~ /\A\p{Alnum}/ + + raise BlockedUrlError, "Hostname needs to start with an alphanumeric character" + end + + def validate_localhost!(addrs_info) + local_ips = ["127.0.0.1", "::1", "0.0.0.0"] + local_ips.concat(Socket.ip_address_list.map(&:ip_address)) + + return if (local_ips & addrs_info.map(&:ip_address)).empty? + + raise BlockedUrlError, "Requests to localhost are not allowed" + end + + def validate_local_network!(addrs_info) + return unless addrs_info.any? { |addr| addr.ipv4_private? || addr.ipv6_sitelocal? } + + raise BlockedUrlError, "Requests to the local network are not allowed" end def internal?(uri) @@ -60,10 +96,6 @@ module Gitlab (uri.port.blank? || uri.port == config.gitlab_shell.ssh_port) end - def private_network?(addrs_info) - addrs_info.any? { |addr| addr.ipv4_private? || addr.ipv6_sitelocal? } - end - def config Gitlab.config end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 37d3512990e..8c0a4d55ea2 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -30,6 +30,7 @@ module Gitlab usage_data end + # rubocop:disable Metrics/AbcSize def system_usage_data { counts: { @@ -50,6 +51,12 @@ module Gitlab clusters: ::Clusters::Cluster.count, clusters_enabled: ::Clusters::Cluster.enabled.count, clusters_disabled: ::Clusters::Cluster.disabled.count, + clusters_platforms_gke: ::Clusters::Cluster.gcp_installed.enabled.count, + clusters_platforms_user: ::Clusters::Cluster.user_provided.enabled.count, + clusters_applications_helm: ::Clusters::Applications::Helm.installed.count, + clusters_applications_ingress: ::Clusters::Applications::Ingress.installed.count, + clusters_applications_prometheus: ::Clusters::Applications::Prometheus.installed.count, + clusters_applications_runner: ::Clusters::Applications::Runner.installed.count, in_review_folder: ::Environment.in_review_folder.count, groups: Group.count, issues: Issue.count, diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index a3012eca04c..b102812ec12 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -21,20 +21,19 @@ module Gitlab raise "Unsupported action: #{action}" unless ALLOWED_GIT_HTTP_ACTIONS.include?(action.to_s) project = repository.project - params = { + + { GL_ID: Gitlab::GlId.gl_id(user), GL_REPOSITORY: Gitlab::GlRepository.gl_repository(project, is_wiki), GL_USERNAME: user&.username, - ShowAllRefs: show_all_refs - } - server = { - address: Gitlab::GitalyClient.address(project.repository_storage), - token: Gitlab::GitalyClient.token(project.repository_storage) + ShowAllRefs: show_all_refs, + Repository: repository.gitaly_repository.to_h, + RepoPath: 'ignored but not allowed to be empty in gitlab-workhorse', + GitalyServer: { + address: Gitlab::GitalyClient.address(project.repository_storage), + token: Gitlab::GitalyClient.token(project.repository_storage) + } } - params[:Repository] = repository.gitaly_repository.to_h - params[:GitalyServer] = server - - params end def artifact_upload_ok diff --git a/lib/tasks/gitlab/two_factor.rake b/lib/tasks/gitlab/two_factor.rake index 7728c485e8d..6b22499a5c8 100644 --- a/lib/tasks/gitlab/two_factor.rake +++ b/lib/tasks/gitlab/two_factor.rake @@ -1,7 +1,7 @@ namespace :gitlab do namespace :two_factor do desc "GitLab | Disable Two-factor authentication (2FA) for all users" - task disable_for_all_users: :environment do + task disable_for_all_users: :gitlab_environment do scope = User.with_two_factor count = scope.count diff --git a/lib/tasks/gitlab/uploads/migrate.rake b/lib/tasks/gitlab/uploads/migrate.rake index c26c3ccb3be..78e18992a8e 100644 --- a/lib/tasks/gitlab/uploads/migrate.rake +++ b/lib/tasks/gitlab/uploads/migrate.rake @@ -13,6 +13,7 @@ namespace :gitlab do def enqueue_batch(batch, index) job = ObjectStorage::MigrateUploadsWorker.enqueue!(batch, + @model_class, @mounted_as, @to_store) puts "Enqueued job ##{index}: #{job}" @@ -25,8 +26,8 @@ namespace :gitlab do Upload.class_eval { include EachBatch } unless Upload < EachBatch Upload - .where.not(store: @to_store) - .where(uploader: @uploader_class.to_s, + .where(store: [nil, ObjectStorage::Store::LOCAL], + uploader: @uploader_class.to_s, model_type: @model_class.base_class.sti_name) end end diff --git a/lib/tasks/test.rake b/lib/tasks/test.rake index 3e01f91d32c..b52af81fc16 100644 --- a/lib/tasks/test.rake +++ b/lib/tasks/test.rake @@ -4,8 +4,3 @@ desc "GitLab | Run all tests" task :test do Rake::Task["gitlab:test"].invoke end - -unless Rails.env.production? - desc "GitLab | Run all tests on CI with simplecov" - task test_ci: [:rubocop, :brakeman, :karma, :spinach, :spec] -end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a04f869f2bb..68d0c0c8854 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: 2018-03-12 19:50+0100\n" -"PO-Revision-Date: 2018-03-12 19:50+0100\n" +"POT-Creation-Date: 2018-03-27 14:40+0300\n" +"PO-Revision-Date: 2018-03-27 14:40+0300\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -226,6 +226,9 @@ msgstr "" msgid "All" msgstr "" +msgid "All changes are committed" +msgstr "" + msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings." msgstr "" @@ -340,6 +343,9 @@ msgstr "" msgid "Assign to" msgstr "" +msgid "Assigned to :name" +msgstr "" + msgid "Assignee" msgstr "" @@ -417,6 +423,9 @@ 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 "Branch has changed" +msgstr "" + msgid "Branch is already taken" msgstr "" @@ -570,6 +579,9 @@ msgstr "" msgid "Cancel" msgstr "" +msgid "Cannot be merged automatically" +msgstr "" + msgid "Cannot modify managed Kubernetes cluster" msgstr "" @@ -1039,6 +1051,9 @@ msgstr "" msgid "Commit statistics for %{ref} %{start_time} - %{end_time}" msgstr "" +msgid "Commit to %{branchName} branch" +msgstr "" + msgid "CommitBoxTitle|Commit" msgstr "" @@ -1084,6 +1099,9 @@ msgstr "" msgid "Compare Revisions" msgstr "" +msgid "Compare changes with the last commit" +msgstr "" + msgid "CompareBranches|%{source_branch} and %{target_branch} are the same." msgstr "" @@ -1099,6 +1117,9 @@ msgstr "" msgid "CompareBranches|There isn't anything to compare." msgstr "" +msgid "Confidential" +msgstr "" + msgid "Confidentiality" msgstr "" @@ -1195,6 +1216,12 @@ msgstr "" msgid "Create New Directory" msgstr "" +msgid "Create a new branch" +msgstr "" + +msgid "Create a new branch and merge request" +msgstr "" + msgid "Create a personal access token on your account to pull or push via %{protocol}." msgstr "" @@ -1204,7 +1231,10 @@ msgstr "" msgid "Create directory" msgstr "" -msgid "Create empty bare repository" +msgid "Create empty repository" +msgstr "" + +msgid "Create file" msgstr "" msgid "Create group label" @@ -1219,6 +1249,15 @@ msgstr "" msgid "Create merge request and branch" msgstr "" +msgid "Create new branch" +msgstr "" + +msgid "Create new directory" +msgstr "" + +msgid "Create new file" +msgstr "" + msgid "Create new label" msgstr "" @@ -1237,6 +1276,12 @@ msgstr "" msgid "CreateTokenToCloneLink|create a personal access token" msgstr "" +msgid "Creates a new branch from %{branchName}" +msgstr "" + +msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request" +msgstr "" + msgid "Cron Timezone" msgstr "" @@ -1311,6 +1356,9 @@ msgstr "" msgid "Directory name" msgstr "" +msgid "Discard draft" +msgstr "" + msgid "Dismiss Cycle Analytics introduction box" msgstr "" @@ -1347,6 +1395,9 @@ msgstr "" msgid "DownloadSource|Download" msgstr "" +msgid "Downvotes" +msgstr "" + msgid "Due date" msgstr "" @@ -1356,6 +1407,12 @@ msgstr "" msgid "Edit Pipeline Schedule %{id}" msgstr "" +msgid "Edit files in the editor and commit changes here" +msgstr "" + +msgid "Editing" +msgstr "" + msgid "Emails" msgstr "" @@ -1410,6 +1467,12 @@ msgstr "" msgid "Environments|You don't have any environments right now." msgstr "" +msgid "Error checking branch data. Please try again." +msgstr "" + +msgid "Error committing changes. Please try again." +msgstr "" + msgid "Error fetching contributors data." msgstr "" @@ -1497,6 +1560,9 @@ msgstr "" msgid "Fields on this page are now uneditable, you can configure" msgstr "" +msgid "File name" +msgstr "" + msgid "Files" msgstr "" @@ -1628,9 +1694,6 @@ msgstr "" msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group." msgstr "" -msgid "GroupsTree|Are you sure you want to leave the \"${group.fullName}\" group?" -msgstr "" - msgid "GroupsTree|Create a project in this group." msgstr "" @@ -1676,6 +1739,9 @@ msgstr "" msgid "HealthCheck|Unhealthy" msgstr "" +msgid "Help" +msgstr "" + msgid "Hide value" msgid_plural "Hide values" msgstr[0] "" @@ -1875,6 +1941,9 @@ msgstr "" msgid "List your GitHub repositories" msgstr "" +msgid "Loading the GitLab IDE..." +msgstr "" + msgid "Lock" msgstr "" @@ -1890,6 +1959,9 @@ msgstr "" msgid "Login" msgstr "" +msgid "Manage all notifications" +msgstr "" + msgid "Manage group labels" msgstr "" @@ -2042,6 +2114,9 @@ msgstr "" msgid "No assignee" msgstr "" +msgid "No changes" +msgstr "" + msgid "No connection could be made to a Gitaly Server, please check your logs!" msgstr "" @@ -2633,6 +2708,9 @@ msgstr "" msgid "Related Merged Requests" msgstr "" +msgid "Related merge requests" +msgstr "" + msgid "Remind later" msgstr "" @@ -2671,6 +2749,12 @@ msgstr "" msgid "Revert this merge request" msgstr "" +msgid "Reviewing" +msgstr "" + +msgid "Runners" +msgstr "" + msgid "Running" msgstr "" @@ -2805,21 +2889,12 @@ msgstr "" msgid "Something went wrong when toggling the button" msgstr "" -msgid "Something went wrong while closing the %{issuable}. Please try again later" -msgstr "" - msgid "Something went wrong while fetching the projects." msgstr "" msgid "Something went wrong while fetching the registry list." msgstr "" -msgid "Something went wrong while reopening the %{issuable}. Please try again later" -msgstr "" - -msgid "Something went wrong while resolving this discussion. Please try again." -msgstr "" - msgid "Something went wrong. Please try again." msgstr "" @@ -3044,6 +3119,9 @@ msgstr "" msgid "Target Branch" msgstr "" +msgid "Target branch" +msgstr "" + msgid "Team" msgstr "" @@ -3435,6 +3513,9 @@ msgstr "" msgid "UploadLink|click to upload" msgstr "" +msgid "Upvotes" +msgstr "" + msgid "Use the following registration token during setup:" msgstr "" @@ -3444,6 +3525,9 @@ msgstr "" msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want." msgstr "" +msgid "View and edit lines" +msgstr "" + msgid "View file @ " msgstr "" @@ -3486,6 +3570,9 @@ msgstr "" msgid "We want to be sure it is you, please confirm you are not a robot." msgstr "" +msgid "Web IDE" +msgstr "" + msgid "Wiki" msgstr "" @@ -3597,6 +3684,9 @@ msgstr "" msgid "Withdraw Access Request" msgstr "" +msgid "Write a commit message..." +msgstr "" + msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?" msgstr "" @@ -3669,12 +3759,18 @@ msgstr "" msgid "You'll need to use different branch names to get a valid comparison." msgstr "" +msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} · %{help_link}" +msgstr "" + msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure" msgstr "" msgid "Your changes can be committed to %{branch_name} because a merge request is open." msgstr "" +msgid "Your changes have been committed. Commit %{commitId} %{commitStats}" +msgstr "" + msgid "Your comment will not be visible to the public." msgstr "" @@ -3902,3 +3998,6 @@ msgstr "" msgid "uses Kubernetes clusters to deploy your code!" msgstr "" + +msgid "with %{additions} additions, %{deletions} deletions." +msgstr "" diff --git a/package.json b/package.json index 31edc3a8016..56fd2575e91 100644 --- a/package.json +++ b/package.json @@ -121,8 +121,5 @@ "nodemon": "^1.15.1", "prettier": "1.11.1", "webpack-dev-server": "^2.11.2" - }, - "optionalDependencies": { - "fsevents": "^1.1.3" } } @@ -90,6 +90,10 @@ module QA autoload :OAuth, 'qa/page/main/oauth' end + module Settings + autoload :Common, 'qa/page/settings/common' + end + module Menu autoload :Main, 'qa/page/menu/main' autoload :Side, 'qa/page/menu/side' @@ -150,7 +154,10 @@ module QA end module Admin - autoload :Settings, 'qa/page/admin/settings' + module Settings + autoload :RepositoryStorage, 'qa/page/admin/settings/repository_storage' + autoload :Main, 'qa/page/admin/settings/main' + end end module Mattermost diff --git a/qa/qa/factory/settings/hashed_storage.rb b/qa/qa/factory/settings/hashed_storage.rb index 13ce2435fe4..c69ebed3c6b 100644 --- a/qa/qa/factory/settings/hashed_storage.rb +++ b/qa/qa/factory/settings/hashed_storage.rb @@ -9,9 +9,11 @@ module QA Page::Menu::Main.act { go_to_admin_area } Page::Menu::Admin.act { go_to_settings } - Page::Admin::Settings.act do - enable_hashed_storage - save_settings + Page::Admin::Settings::Main.perform do |setting| + setting.expand_repository_storage do |page| + page.enable_hashed_storage + page.save_settings + end end QA::Page::Menu::Main.act { sign_out } diff --git a/qa/qa/page/admin/settings.rb b/qa/qa/page/admin/settings.rb deleted file mode 100644 index 1f646103e7f..00000000000 --- a/qa/qa/page/admin/settings.rb +++ /dev/null @@ -1,26 +0,0 @@ -module QA - module Page - module Admin - class Settings < Page::Base - view 'app/views/admin/application_settings/_form.html.haml' do - element :form_actions, '.form-actions' - element :submit, "submit 'Save'" - element :repository_storage, '%legend Repository Storage' - element :hashed_storage, - 'Create new projects using hashed storage paths' - end - - def enable_hashed_storage - scroll_to 'legend', text: 'Repository Storage' - check 'Create new projects using hashed storage paths' - end - - def save_settings - scroll_to '.form-actions' do - click_button 'Save' - end - end - end - end - end -end diff --git a/qa/qa/page/admin/settings/main.rb b/qa/qa/page/admin/settings/main.rb new file mode 100644 index 00000000000..e7c1220c967 --- /dev/null +++ b/qa/qa/page/admin/settings/main.rb @@ -0,0 +1,21 @@ +module QA + module Page + module Admin + module Settings + class Main < Page::Base + include QA::Page::Settings::Common + + view 'app/views/admin/application_settings/show.html.haml' do + element :advanced_settings_section, 'Repository storage' + end + + def expand_repository_storage(&block) + expand_section('Repository storage') do + RepositoryStorage.perform(&block) + end + end + end + end + end + end +end diff --git a/qa/qa/page/admin/settings/repository_storage.rb b/qa/qa/page/admin/settings/repository_storage.rb new file mode 100644 index 00000000000..b4a1344216e --- /dev/null +++ b/qa/qa/page/admin/settings/repository_storage.rb @@ -0,0 +1,23 @@ +module QA + module Page + module Admin + module Settings + class RepositoryStorage < Page::Base + view 'app/views/admin/application_settings/_repository_storage.html.haml' do + element :submit, "submit 'Save changes'" + element :hashed_storage, + 'Create new projects using hashed storage paths' + end + + def enable_hashed_storage + check 'Create new projects using hashed storage paths' + end + + def save_settings + click_button 'Save changes' + end + end + end + end + end +end diff --git a/qa/qa/page/project/settings/common.rb b/qa/qa/page/project/settings/common.rb index 319cb1045b6..874fb381554 100644 --- a/qa/qa/page/project/settings/common.rb +++ b/qa/qa/page/project/settings/common.rb @@ -3,6 +3,8 @@ module QA module Project module Settings module Common + include QA::Page::Settings::Common + def self.included(base) base.class_eval do view 'app/views/projects/edit.html.haml' do @@ -10,24 +12,6 @@ module QA end end end - - # Click the Expand button present in the specified section - # - # @param [String] name present in the container in the DOM - def expand_section(name) - page.within('#content-body') do - page.within('section', text: name) do - # Because it is possible to click the button before the JS toggle code is bound - wait(reload: false) do - click_button 'Expand' unless first('button', text: 'Collapse') - - page.has_content?('Collapse') - end - - yield if block_given? - end - end - end end end end diff --git a/qa/qa/page/settings/common.rb b/qa/qa/page/settings/common.rb new file mode 100644 index 00000000000..a683a6829d5 --- /dev/null +++ b/qa/qa/page/settings/common.rb @@ -0,0 +1,25 @@ +module QA + module Page + module Settings + module Common + # Click the Expand button present in the specified section + # + # @param [String] name present in the container in the DOM + def expand_section(name) + page.within('#content-body') do + page.within('section', text: name) do + # Because it is possible to click the button before the JS toggle code is bound + wait(reload: false) do + click_button 'Expand' unless first('button', text: 'Collapse') + + page.has_content?('Collapse') + end + + yield if block_given? + end + end + end + end + end + end +end diff --git a/qa/qa/scenario/bootable.rb b/qa/qa/scenario/bootable.rb index d6de4d404c8..dd12ea6d492 100644 --- a/qa/qa/scenario/bootable.rb +++ b/qa/qa/scenario/bootable.rb @@ -23,7 +23,7 @@ module QA arguments.parse!(argv) - self.perform(**Runtime::Scenario.attributes) + self.perform(Runtime::Scenario.attributes, *arguments.default_argv) end private diff --git a/qa/qa/scenario/test/instance.rb b/qa/qa/scenario/test/instance.rb index 0af9afd1ea4..567e5fd6cca 100644 --- a/qa/qa/scenario/test/instance.rb +++ b/qa/qa/scenario/test/instance.rb @@ -11,7 +11,7 @@ module QA tags :core - def perform(address, *files) + def perform(address, *rspec_options) Runtime::Scenario.define(:gitlab_address, address) ## @@ -22,9 +22,9 @@ module QA Specs::Runner.perform do |specs| specs.tty = true specs.tags = self.class.focus - specs.files = - if files.any? - files + specs.options = + if rspec_options.any? + rspec_options else File.expand_path('../../specs/features', __dir__) end diff --git a/qa/qa/scenario/test/integration/mattermost.rb b/qa/qa/scenario/test/integration/mattermost.rb index d939f52ab16..13bfad28b0b 100644 --- a/qa/qa/scenario/test/integration/mattermost.rb +++ b/qa/qa/scenario/test/integration/mattermost.rb @@ -9,10 +9,10 @@ module QA class Mattermost < Test::Instance tags :core, :mattermost - def perform(address, mattermost, *files) + def perform(address, mattermost, *rspec_options) Runtime::Scenario.define(:mattermost_address, mattermost) - super(address, *files) + super(address, *rspec_options) end end end diff --git a/qa/qa/specs/runner.rb b/qa/qa/specs/runner.rb index 752e3e60b8c..f8f6fe65599 100644 --- a/qa/qa/specs/runner.rb +++ b/qa/qa/specs/runner.rb @@ -3,19 +3,19 @@ require 'rspec/core' module QA module Specs class Runner < Scenario::Template - attr_accessor :tty, :tags, :files + attr_accessor :tty, :tags, :options def initialize @tty = false @tags = [] - @files = [File.expand_path('./features', __dir__)] + @options = [File.expand_path('./features', __dir__)] end def perform args = [] args.push('--tty') if tty tags.to_a.each { |tag| args.push(['-t', tag.to_s]) } - args.push(files) + args.push(options) Runtime::Browser.configure! diff --git a/qa/spec/page/validator_spec.rb b/qa/spec/page/validator_spec.rb index 02822d7d18f..55957649904 100644 --- a/qa/spec/page/validator_spec.rb +++ b/qa/spec/page/validator_spec.rb @@ -30,7 +30,7 @@ describe QA::Page::Validator do let(:view) { spy('view') } before do - allow(QA::Page::Admin::Settings) + allow(QA::Page::Admin::Settings::Main) .to receive(:views).and_return([view]) end diff --git a/qa/spec/scenario/test/instance_spec.rb b/qa/spec/scenario/test/instance_spec.rb index bd09c28e924..a74a9538be8 100644 --- a/qa/spec/scenario/test/instance_spec.rb +++ b/qa/spec/scenario/test/instance_spec.rb @@ -29,7 +29,7 @@ describe QA::Scenario::Test::Instance do it 'should call runner with default arguments' do subject.perform("test") - expect(runner).to have_received(:files=) + expect(runner).to have_received(:options=) .with(File.expand_path('../../../qa/specs/features', __dir__)) end end @@ -38,7 +38,7 @@ describe QA::Scenario::Test::Instance do it 'should call runner with paths' do subject.perform('test', 'path1', 'path2') - expect(runner).to have_received(:files=).with(%w[path1 path2]) + expect(runner).to have_received(:options=).with(%w[path1 path2]) end end end diff --git a/scripts/codequality b/scripts/codequality deleted file mode 100755 index 2f3ccef7d2d..00000000000 --- a/scripts/codequality +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh - -set -eo pipefail - -code_path=$(pwd) - -# docker run --tty will merge stderr and stdout, we don't need this on CI or -# it will break codequality json file -[ "$CI" != "" ] || docker_tty="--tty" - -# The codebase and instructions for the following image can be found at https://gitlab.com/gitlab-org/codeclimate-rubocop/wikis/home -docker pull dev.gitlab.org:5005/gitlab/gitlab-build-images:gitlab-codeclimate-rubocop-0-52-1 > /dev/null -docker tag dev.gitlab.org:5005/gitlab/gitlab-build-images:gitlab-codeclimate-rubocop-0-52-1 codeclimate/codeclimate-rubocop:gitlab-codeclimate-rubocop-0-52-1 > /dev/null - -exec docker run --rm $docker_tty --env CODECLIMATE_CODE="$code_path" \ - --volume "$code_path":/code \ - --volume /var/run/docker.sock:/var/run/docker.sock \ - --volume /tmp/cc:/tmp/cc \ - "codeclimate/codeclimate:${CODECLIMATE_VERSION:-0.71.1}" "$@" diff --git a/scripts/trigger-build-omnibus b/scripts/trigger-build-omnibus index 85ea4aa74ac..95f35b44f5a 100755 --- a/scripts/trigger-build-omnibus +++ b/scripts/trigger-build-omnibus @@ -9,6 +9,7 @@ module Omnibus class Trigger TOKEN = ENV['BUILD_TRIGGER_TOKEN'] + TRIGGERER = ENV['CI_PROJECT_NAME'] def initialize @uri = URI("https://gitlab.com/api/v4/projects/#{CGI.escape(Omnibus::PROJECT_PATH)}/trigger/pipeline") @@ -32,7 +33,7 @@ module Omnibus private def ee? - File.exist?('CHANGELOG-EE.md') + TRIGGERER == 'gitlab-ee' || File.exist?('CHANGELOG-EE.md') end def env_params diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb index 03cbbb21e62..891485406c6 100644 --- a/spec/controllers/profiles_controller_spec.rb +++ b/spec/controllers/profiles_controller_spec.rb @@ -84,6 +84,13 @@ describe ProfilesController, :request_store do expect(user.username).to eq(new_username) end + it 'raises a correct error when the username is missing' do + sign_in(user) + + expect { put :update_username, user: { gandalf: 'you shall not pass' } } + .to raise_error(ActionController::ParameterMissing) + end + context 'with legacy storage' do it 'moves dependent projects to new namespace' do project = create(:project_empty_repo, :legacy_storage, namespace: namespace) diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index 3b9e06cb5ad..16fb377b002 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -398,6 +398,22 @@ describe Projects::BranchesController do end end + # We need :request_store because Gitaly only counts the queries whenever + # `RequestStore.active?` in GitalyClient.enforce_gitaly_request_limits + # And the main goal of this test is making sure TooManyInvocationsError + # was not raised whenever the cache is enabled yet cold. + context 'when cache is enabled yet cold', :request_store do + it 'return with a status 200' do + get :index, + namespace_id: project.namespace, + project_id: project, + state: 'all', + format: :html + + expect(response).to have_gitlab_http_status(200) + end + end + context 'when branch contains an invalid UTF-8 sequence' do before do project.repository.create_branch("wrong-\xE5-utf8-sequence") @@ -414,7 +430,7 @@ describe Projects::BranchesController do end end - context 'when depreated sort/search/page parameters are specified' do + context 'when deprecated sort/search/page parameters are specified' do it 'returns with a status 301 when sort specified' do get :index, namespace_id: project.namespace, diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 9918d52e402..01b5506b64b 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -974,7 +974,7 @@ describe Projects::IssuesController do it 'returns discussion json' do get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid - expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion individual_note resolvable resolve_with_issue_path resolved]) + expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion individual_note resolvable resolved]) end context 'with cross-reference system note', :request_store do diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 5b2614163ff..548c5ef36e7 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -107,7 +107,7 @@ describe Projects::MilestonesController do it 'shows group milestone' do post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid - expect(flash[:notice]).to eq("#{milestone.title} promoted to group milestone") + expect(flash[:notice]).to eq("#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, milestone.iid)}\">group milestone</a>.") expect(response).to redirect_to(project_milestones_path(project)) end end diff --git a/spec/factories/ci/build_metadata.rb b/spec/factories/ci/build_metadata.rb new file mode 100644 index 00000000000..66bbd977b88 --- /dev/null +++ b/spec/factories/ci/build_metadata.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :ci_build_metadata, class: Ci::BuildMetadata do + build factory: :ci_build + + after(:build) do |build_metadata, _| + build_metadata.project ||= build_metadata.build.project + end + end +end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index c89bc54cad4..3005d74c3cf 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -120,6 +120,53 @@ feature 'Admin updates settings' do expect(page).to have_content "Application settings saved successfully" end + scenario 'Change Performance bar settings' do + group = create(:group) + + page.within('.as-performance') do + check 'Enable the Performance Bar' + fill_in 'Allowed group', with: group.path + click_on 'Save changes' + end + + expect(page).to have_content "Application settings saved successfully" + expect(find_field('Enable the Performance Bar')).to be_checked + expect(find_field('Allowed group').value).to eq group.path + + page.within('.as-performance') do + uncheck 'Enable the Performance Bar' + click_on 'Save changes' + end + + expect(page).to have_content 'Application settings saved successfully' + expect(find_field('Enable the Performance Bar')).not_to be_checked + expect(find_field('Allowed group').value).to be_nil + end + + scenario 'Change Background jobs settings' do + page.within('.as-background') do + fill_in 'Throttling Factor', with: 1 + click_button 'Save changes' + end + + expect(Gitlab::CurrentSettings.sidekiq_throttling_factor).to eq(1) + expect(page).to have_content "Application settings saved successfully" + end + + scenario 'Change Spam settings' do + page.within('.as-spam') do + check 'Enable reCAPTCHA' + fill_in 'reCAPTCHA Site Key', with: 'key' + fill_in 'reCAPTCHA Private Key', with: 'key' + fill_in 'IPs per user', with: 15 + click_button 'Save changes' + end + + expect(page).to have_content "Application settings saved successfully" + expect(Gitlab::CurrentSettings.recaptcha_enabled).to be true + expect(Gitlab::CurrentSettings.unique_ips_limit_per_user).to eq(15) + end + scenario 'Change Slack Notifications Service template settings' do first(:link, 'Service Templates').click click_link 'Slack notifications' @@ -172,29 +219,6 @@ feature 'Admin updates settings' do expect(find_field('ED25519 SSH keys').value).to eq(forbidden) end - scenario 'Change Performance Bar settings' do - group = create(:group) - - check 'Enable the Performance Bar' - fill_in 'Allowed group', with: group.path - - click_on 'Save' - - expect(page).to have_content 'Application settings saved successfully' - - expect(find_field('Enable the Performance Bar')).to be_checked - expect(find_field('Allowed group').value).to eq group.path - - uncheck 'Enable the Performance Bar' - - click_on 'Save' - - expect(page).to have_content 'Application settings saved successfully' - - expect(find_field('Enable the Performance Bar')).not_to be_checked - expect(find_field('Allowed group').value).to be_nil - end - def check_all_events page.check('Active') page.check('Push') diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb index 8759950e013..029fc45c791 100644 --- a/spec/features/dashboard/issues_filter_spec.rb +++ b/spec/features/dashboard/issues_filter_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' feature 'Dashboard Issues filtering', :js do - include SortingHelper + include Spec::Support::Helpers::Features::SortingHelpers let(:user) { create(:user) } let(:project) { create(:project) } @@ -90,14 +90,14 @@ feature 'Dashboard Issues filtering', :js do context 'sorting' do it 'shows sorted issues' do - sorting_by('Created date') + sort_by('Created date') visit_issues expect(find('.issues-filters')).to have_content('Created date') end it 'keeps sorting issues after visiting Projects Issues page' do - sorting_by('Created date') + sort_by('Created date') visit project_issues_path(project) expect(find('.issues-filters')).to have_content('Created date') diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index c8f3a8449f5..4a9344115d2 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' feature 'Dashboard Merge Requests' do + include Spec::Support::Helpers::Features::SortingHelpers include FilterItemSelectHelper - include SortingHelper include ProjectForksHelper let(:current_user) { create :user } @@ -115,7 +115,7 @@ feature 'Dashboard Merge Requests' do end it 'shows sorted merge requests' do - sorting_by('Created date') + sort_by('Created date') visit merge_requests_dashboard_path(assignee_id: current_user.id) @@ -123,7 +123,7 @@ feature 'Dashboard Merge Requests' do end it 'keeps sorting merge requests after visiting Projects MR page' do - sorting_by('Created date') + sort_by('Created date') visit project_merge_requests_path(project) diff --git a/spec/features/groups/activity_spec.rb b/spec/features/groups/activity_spec.rb index d3b25ec3d6c..7bc809b3104 100644 --- a/spec/features/groups/activity_spec.rb +++ b/spec/features/groups/activity_spec.rb @@ -8,11 +8,30 @@ feature 'Group activity page' do context 'when signed in' do before do sign_in(user) - visit path 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" + describe 'RSS' do + before do + visit path + 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 + + context 'when project is in the group', :js do + let(:project) { create(:project, :public, namespace: group) } + + before do + project.add_master(user) + + visit path + end + + it 'renders user joined to project event' do + expect(page).to have_content 'joined project' + end + end end context 'when signed out' do diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb index b83bad3befb..1ce30015e81 100644 --- a/spec/features/groups/group_settings_spec.rb +++ b/spec/features/groups/group_settings_spec.rb @@ -76,6 +76,27 @@ feature 'Edit group settings' do end end end + + describe 'edit group avatar' do + before do + visit edit_group_path(group) + + attach_file(:group_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif')) + + expect { click_button 'Save group' }.to change { group.reload.avatar? }.to(true) + end + + it 'uploads new group avatar' do + expect(group.avatar).to be_instance_of AvatarUploader + expect(group.avatar.url).to eq "/uploads/-/system/group/avatar/#{group.id}/banana_sample.gif" + expect(page).to have_link('Remove avatar') + end + + it 'removes group avatar' do + expect { click_link 'Remove avatar' }.to change { group.reload.avatar? }.to(false) + expect(page).not_to have_link('Remove avatar') + end + end end def update_path(new_group_path) diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index 450bc0ff8cf..90bf7ba49f6 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -3,8 +3,11 @@ require 'spec_helper' feature 'Group issues page' do include FilteredSearchHelpers + let(:group) { create(:group) } + let(:project) { create(:project, :public, group: group)} + let(:path) { issues_group_path(group) } + context 'with shared examples' do - let(:path) { issues_group_path(group) } let(:issuable) { create(:issue, project: project, title: "this is my created issuable")} include_examples 'project features apply to issuables', Issue @@ -31,7 +34,6 @@ feature 'Group issues page' do let(:access_level) { ProjectFeature::ENABLED } let(:user) { user_in_group } let(:user2) { user_outside_group } - let(:path) { issues_group_path(group) } it 'filters by only group users' do filtered_search.set('assignee:') @@ -43,9 +45,7 @@ feature 'Group issues page' do end context 'issues list', :nested_groups do - let(:group) { create(:group)} let(:subgroup) { create(:group, parent: group) } - let(:project) { create(:project, :public, group: group)} let(:subgroup_project) { create(:project, :public, group: subgroup)} let!(:issue) { create(:issue, project: project, title: 'root group issue') } let!(:subgroup_issue) { create(:issue, project: subgroup_project, title: 'subgroup issue') } @@ -59,5 +59,17 @@ feature 'Group issues page' do expect(page).to have_content('subgroup issue') end end + + context 'when project is archived' do + before do + project.archive! + end + + it 'does not render issue' do + visit path + + expect(page).not_to have_content issue.title[0..80] + end + end end end diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb index 7ce6a61d50c..672ae785c2d 100644 --- a/spec/features/groups/merge_requests_spec.rb +++ b/spec/features/groups/merge_requests_spec.rb @@ -5,14 +5,14 @@ feature 'Group merge requests page' do let(:path) { merge_requests_group_path(group) } let(:issuable) { create(:merge_request, source_project: project, target_project: project, title: 'this is my created issuable') } + let(:access_level) { ProjectFeature::ENABLED } + let(:user) { user_in_group } include_examples 'project features apply to issuables', MergeRequest context 'archived issuable' do let(:project_archived) { create(:project, :archived, :merge_requests_enabled, :repository, group: group) } let(:issuable_archived) { create(:merge_request, source_project: project_archived, target_project: project_archived, title: 'issuable of an archived project') } - let(:access_level) { ProjectFeature::ENABLED } - let(:user) { user_in_group } before do issuable_archived @@ -36,9 +36,17 @@ feature 'Group merge requests page' do end end + context 'when merge request assignee to user' do + before do + issuable.update!(assignee: user) + + visit path + end + + it { expect(page).to have_content issuable.title[0..80] } + end + context 'group filtered search', :js do - let(:access_level) { ProjectFeature::ENABLED } - let(:user) { user_in_group } let(:user2) { user_outside_group } it 'filters by assignee only group users' do diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb index ceccc471405..4ffadbbcd35 100644 --- a/spec/features/groups/show_spec.rb +++ b/spec/features/groups/show_spec.rb @@ -15,14 +15,44 @@ feature 'Group show page' do end it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" + + context 'when group does not exist' do + let(:path) { group_path('not-exist') } + + it { expect(status_code).to eq(404) } + end end context 'when signed out' do - before do - visit path + describe 'RSS' do + before do + visit path + end + + it_behaves_like "an autodiscoverable RSS feed without an RSS token" + end + + context 'when group has a public project', :js do + let!(:project) { create(:project, :public, namespace: group) } + + it 'renders public project' do + visit path + + expect(page).to have_link group.name + expect(page).to have_link project.name + end end - it_behaves_like "an autodiscoverable RSS feed without an RSS token" + context 'when group has a private project', :js do + let!(:project) { create(:project, :private, namespace: group) } + + it 'does not render private project' do + visit path + + expect(page).to have_link group.name + expect(page).not_to have_link project.name + end + end end context 'subgroup support' do diff --git a/spec/features/groups/user_browse_projects_group_page_spec.rb b/spec/features/groups/user_browse_projects_group_page_spec.rb new file mode 100644 index 00000000000..e81c3180e78 --- /dev/null +++ b/spec/features/groups/user_browse_projects_group_page_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +describe 'User browse group projects page' do + let(:user) { create :user } + let(:group) { create :group } + + context 'when user is owner' do + before do + group.add_owner(user) + end + + context 'when user signed in' do + before do + sign_in(user) + end + + context 'when group has archived project', :js do + let!(:project) { create :project, :archived, namespace: group } + + it 'renders projects list' do + visit projects_group_path(group) + + expect(page).to have_link project.name + expect(page).to have_xpath("//span[@class='label label-warning']", text: 'archived') + end + end + end + end +end diff --git a/spec/features/issuables/discussion_lock_spec.rb b/spec/features/issuables/discussion_lock_spec.rb index ecbe51a7bc2..7ea29ff252b 100644 --- a/spec/features/issuables/discussion_lock_spec.rb +++ b/spec/features/issuables/discussion_lock_spec.rb @@ -14,7 +14,7 @@ describe 'Discussion Lock', :js do project.add_developer(user) end - context 'when the discussion is unlocked' do + context 'when the discussion is unlocked' do it 'the user can lock the issue' do visit project_issue_path(project, issue) diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index b835558b142..27551bb70ee 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -161,6 +161,50 @@ feature 'Issue Sidebar' do end end end + + context 'interacting with collapsed sidebar', :js do + collapsed_sidebar_selector = 'aside.right-sidebar.right-sidebar-collapsed' + expanded_sidebar_selector = 'aside.right-sidebar.right-sidebar-expanded' + confidentiality_sidebar_block = '.block.confidentiality' + lock_sidebar_block = '.block.lock' + collapsed_sidebar_block_icon = '.sidebar-collapsed-icon' + + before do + resize_screen_sm + end + + it 'confidentiality block expands then collapses sidebar' do + expect(page).to have_css(collapsed_sidebar_selector) + + page.within(confidentiality_sidebar_block) do + find(collapsed_sidebar_block_icon).click + end + + expect(page).to have_css(expanded_sidebar_selector) + + page.within(confidentiality_sidebar_block) do + page.find('button', text: 'Cancel').click + end + + expect(page).to have_css(collapsed_sidebar_selector) + end + + it 'lock block expands then collapses sidebar' do + expect(page).to have_css(collapsed_sidebar_selector) + + page.within(lock_sidebar_block) do + find(collapsed_sidebar_block_icon).click + end + + expect(page).to have_css(expanded_sidebar_selector) + + page.within(lock_sidebar_block) do + page.find('button', text: 'Cancel').click + end + + expect(page).to have_css(collapsed_sidebar_selector) + end + end end context 'as a guest' do diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index ea7a97d02a0..ff2a0e15719 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' feature 'Issues > User uses quick actions', :js do - include QuickActionsHelpers + include Spec::Support::Helpers::Features::NotesHelpers it_behaves_like 'issuable record that supports quick actions in its description and notes', :issue do let(:issuable) { create(:issue, project: project) } @@ -36,7 +36,7 @@ feature 'Issues > User uses quick actions', :js do context 'when the current user can update the due date' do it 'does not create a note, and sets the due date accordingly' do - write_note("/due 2016-08-28") + add_note("/due 2016-08-28") expect(page).not_to have_content '/due 2016-08-28' expect(page).to have_content 'Commands applied' @@ -57,7 +57,7 @@ feature 'Issues > User uses quick actions', :js do end it 'does not create a note, and sets the due date accordingly' do - write_note("/due 2016-08-28") + add_note("/due 2016-08-28") expect(page).not_to have_content 'Commands applied' @@ -75,7 +75,7 @@ feature 'Issues > User uses quick actions', :js do it 'does not create a note, and removes the due date accordingly' do expect(issue.due_date).to eq Date.new(2016, 8, 28) - write_note("/remove_due_date") + add_note("/remove_due_date") expect(page).not_to have_content '/remove_due_date' expect(page).to have_content 'Commands applied' @@ -96,7 +96,7 @@ feature 'Issues > User uses quick actions', :js do end it 'does not create a note, and sets the due date accordingly' do - write_note("/remove_due_date") + add_note("/remove_due_date") expect(page).not_to have_content 'Commands applied' @@ -111,7 +111,7 @@ feature 'Issues > User uses quick actions', :js do let(:issue) { create(:issue, project: project) } it 'does not recognize the command nor create a note' do - write_note("/wip") + add_note("/wip") expect(page).not_to have_content '/wip' end @@ -123,7 +123,7 @@ feature 'Issues > User uses quick actions', :js do context 'when the current user can update issues' do it 'does not create a note, and marks the issue as a duplicate' do - write_note("/duplicate ##{original_issue.to_reference}") + add_note("/duplicate ##{original_issue.to_reference}") expect(page).not_to have_content "/duplicate #{original_issue.to_reference}" expect(page).to have_content 'Commands applied' @@ -143,7 +143,7 @@ feature 'Issues > User uses quick actions', :js do end it 'does not create a note, and does not mark the issue as a duplicate' do - write_note("/duplicate ##{original_issue.to_reference}") + add_note("/duplicate ##{original_issue.to_reference}") expect(page).not_to have_content 'Commands applied' expect(page).not_to have_content "marked this issue as a duplicate of #{original_issue.to_reference}" @@ -166,7 +166,7 @@ feature 'Issues > User uses quick actions', :js do end it 'moves the issue' do - write_note("/move #{target_project.full_path}") + add_note("/move #{target_project.full_path}") expect(page).to have_content 'Commands applied' expect(issue.reload).to be_closed @@ -186,7 +186,7 @@ feature 'Issues > User uses quick actions', :js do end it 'does not move the issue' do - write_note("/move #{project_unauthorized.full_path}") + add_note("/move #{project_unauthorized.full_path}") expect(page).not_to have_content 'Commands applied' expect(issue.reload).to be_open @@ -200,7 +200,7 @@ feature 'Issues > User uses quick actions', :js do end it 'does not move the issue' do - write_note("/move not/valid") + add_note("/move not/valid") expect(page).not_to have_content 'Commands applied' expect(issue.reload).to be_open @@ -223,7 +223,7 @@ feature 'Issues > User uses quick actions', :js do end it 'applies the commands to both issues and moves the issue' do - write_note("/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"\n\n/move #{target_project.full_path}") + add_note("/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"\n\n/move #{target_project.full_path}") expect(page).to have_content 'Commands applied' expect(issue.reload).to be_closed @@ -242,7 +242,7 @@ feature 'Issues > User uses quick actions', :js do end it 'moves the issue and applies the commands to both issues' do - write_note("/move #{target_project.full_path}\n\n/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"") + add_note("/move #{target_project.full_path}\n\n/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"") expect(page).to have_content 'Commands applied' expect(issue.reload).to be_closed diff --git a/spec/features/merge_request/user_uses_slash_commands_spec.rb b/spec/features/merge_request/user_uses_slash_commands_spec.rb index bd739e69d6c..7f261b580f7 100644 --- a/spec/features/merge_request/user_uses_slash_commands_spec.rb +++ b/spec/features/merge_request/user_uses_slash_commands_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe 'Merge request > User uses quick actions', :js do - include QuickActionsHelpers + include Spec::Support::Helpers::Features::NotesHelpers let(:project) { create(:project, :public, :repository) } let(:user) { project.creator } @@ -33,7 +33,7 @@ describe 'Merge request > User uses quick actions', :js do describe 'toggling the WIP prefix in the title from note' do context 'when the current user can toggle the WIP prefix' do it 'adds the WIP: prefix to the title' do - write_note("/wip") + add_note("/wip") expect(page).not_to have_content '/wip' expect(page).to have_content 'Commands applied' @@ -44,7 +44,7 @@ describe 'Merge request > User uses quick actions', :js do it 'removes the WIP: prefix from the title' do merge_request.title = merge_request.wip_title merge_request.save - write_note("/wip") + add_note("/wip") expect(page).not_to have_content '/wip' expect(page).to have_content 'Commands applied' @@ -62,7 +62,7 @@ describe 'Merge request > User uses quick actions', :js do end it 'does not change the WIP prefix' do - write_note("/wip") + add_note("/wip") expect(page).not_to have_content '/wip' expect(page).not_to have_content 'Commands applied' @@ -75,7 +75,7 @@ describe 'Merge request > User uses quick actions', :js do describe 'merging the MR from the note' do context 'when the current user can merge the MR' do it 'merges the MR' do - write_note("/merge") + add_note("/merge") expect(page).to have_content 'Commands applied' @@ -90,7 +90,7 @@ describe 'Merge request > User uses quick actions', :js do end it 'does not merge the MR' do - write_note("/merge") + add_note("/merge") expect(page).not_to have_content 'Your commands have been executed!' @@ -107,7 +107,7 @@ describe 'Merge request > User uses quick actions', :js do end it 'does not merge the MR' do - write_note("/merge") + add_note("/merge") expect(page).not_to have_content 'Your commands have been executed!' @@ -118,7 +118,7 @@ describe 'Merge request > User uses quick actions', :js do describe 'adding a due date from note' do it 'does not recognize the command nor create a note' do - write_note('/due 2016-08-28') + add_note('/due 2016-08-28') expect(page).not_to have_content '/due 2016-08-28' end @@ -162,7 +162,7 @@ describe 'Merge request > User uses quick actions', :js do describe '/target_branch command from note' do context 'when the current user can change target branch' do it 'changes target branch from a note' do - write_note("message start \n/target_branch merge-test\n message end.") + add_note("message start \n/target_branch merge-test\n message end.") wait_for_requests expect(page).not_to have_content('/target_branch') @@ -173,7 +173,7 @@ describe 'Merge request > User uses quick actions', :js do end it 'does not fail when target branch does not exists' do - write_note('/target_branch totally_not_existing_branch') + add_note('/target_branch totally_not_existing_branch') expect(page).not_to have_content('/target_branch') @@ -190,7 +190,7 @@ describe 'Merge request > User uses quick actions', :js do end it 'does not change target branch' do - write_note('/target_branch merge-test') + add_note('/target_branch merge-test') expect(page).not_to have_content '/target_branch merge-test' diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb index 171e061e60e..e8eb0d17ca4 100644 --- a/spec/features/profiles/account_spec.rb +++ b/spec/features/profiles/account_spec.rb @@ -43,14 +43,14 @@ feature 'Profile > Account' do update_username(new_username) visit new_project_path expect(current_path).to eq(new_project_path) - expect(find('.breadcrumbs-sub-title')).to have_content(project.path) + expect(find('.breadcrumbs-sub-title')).to have_content('Details') end scenario 'the old project path redirects to the new path' do update_username(new_username) visit old_project_path expect(current_path).to eq(new_project_path) - expect(find('.breadcrumbs-sub-title')).to have_content(project.path) + expect(find('.breadcrumbs-sub-title')).to have_content('Details') end end end diff --git a/spec/features/projects/issues/user_comments_on_issue_spec.rb b/spec/features/projects/issues/user_comments_on_issue_spec.rb new file mode 100644 index 00000000000..c45fdc7642f --- /dev/null +++ b/spec/features/projects/issues/user_comments_on_issue_spec.rb @@ -0,0 +1,73 @@ +require "spec_helper" + +describe "User comments on issue", :js do + include Spec::Support::Helpers::Features::NotesHelpers + + let(:project) { create(:project_empty_repo, :public) } + let(:issue) { create(:issue, project: project) } + let(:user) { create(:user) } + + before do + project.add_guest(user) + sign_in(user) + + visit(project_issue_path(project, issue)) + end + + context "when adding comments" do + it "adds comment" do + content = "XML attached" + target_form = ".js-main-target-form" + + add_note(content) + + page.within(".note") do + expect(page).to have_content(content) + end + + page.within(target_form) do + find(".error-alert", visible: false) + end + end + + it "adds comment with code block" do + comment = "```\nCommand [1]: /usr/local/bin/git , see [text](doc/text)\n```" + + add_note(comment) + + expect(page).to have_content(comment) + end + end + + context "when editing comments" do + it "edits comment" do + add_note("# Comment with a header") + + page.within(".note-body > .note-text") do + expect(page).to have_content("Comment with a header").and have_no_css("#comment-with-a-header") + end + + page.within(".main-notes-list") do + note = find(".note") + + note.hover + note.find(".js-note-edit").click + end + + expect(page).to have_css(".current-note-edit-form textarea") + + comment = "+1 Awesome!" + + page.within(".current-note-edit-form") do + fill_in("note[note]", with: comment) + click_button("Save comment") + end + + wait_for_requests + + page.within(".note") do + expect(page).to have_content(comment) + end + end + end +end diff --git a/spec/features/projects/issues/user_creates_issue_spec.rb b/spec/features/projects/issues/user_creates_issue_spec.rb new file mode 100644 index 00000000000..e76f7c5589d --- /dev/null +++ b/spec/features/projects/issues/user_creates_issue_spec.rb @@ -0,0 +1,87 @@ +require "spec_helper" + +describe "User creates issue" do + let(:project) { create(:project_empty_repo, :public) } + let(:user) { create(:user) } + + context "when signed in as guest" do + before do + project.add_guest(user) + sign_in(user) + + visit(new_project_issue_path(project)) + end + + it "creates issue" do + page.within(".issue-form") do + expect(page).to have_no_content("Assign to") + .and have_no_content("Labels") + .and have_no_content("Milestone") + end + + issue_title = "500 error on profile" + + fill_in("Title", with: issue_title) + click_button("Submit issue") + + expect(page).to have_content(issue_title) + .and have_content(user.name) + .and have_content(project.name) + end + end + + context "when signed in as developer", :js do + before do + project.add_developer(user) + sign_in(user) + + visit(new_project_issue_path(project)) + end + + context "when previewing" do + it "previews content" do + form = first(".gfm-form") + textarea = first(".gfm-form textarea") + + page.within(form) do + click_link("Preview") + + preview = find(".js-md-preview") # this element is findable only when the "Preview" link is clicked. + + expect(preview).to have_content("Nothing to preview.") + + click_link("Write") + fill_in("Description", with: "Bug fixed :smile:") + click_link("Preview") + + expect(preview).to have_css("gl-emoji") + expect(textarea).not_to be_visible + end + end + end + + context "with labels" do + LABEL_TITLES = %w(bug feature enhancement).freeze + + before do + LABEL_TITLES.each do |title| + create(:label, project: project, title: title) + end + end + + it "creates issue" do + issue_title = "500 error on profile" + + fill_in("Title", with: issue_title) + click_button("Label") + click_link(LABEL_TITLES.first) + click_button("Submit issue") + + expect(page).to have_content(issue_title) + .and have_content(user.name) + .and have_content(project.name) + .and have_content(LABEL_TITLES.first) + end + end + end +end diff --git a/spec/features/projects/issues/user_edits_issue_spec.rb b/spec/features/projects/issues/user_edits_issue_spec.rb new file mode 100644 index 00000000000..1d9c3abc20f --- /dev/null +++ b/spec/features/projects/issues/user_edits_issue_spec.rb @@ -0,0 +1,25 @@ +require "spec_helper" + +describe "User edits issue", :js do + set(:project) { create(:project_empty_repo, :public) } + set(:user) { create(:user) } + set(:issue) { create(:issue, project: project, author: user) } + + before do + project.add_developer(user) + sign_in(user) + + visit(edit_project_issue_path(project, issue)) + end + + it "previews content" do + form = first(".gfm-form") + + page.within(form) do + fill_in("Description", with: "Bug fixed :smile:") + click_link("Preview") + end + + expect(form).to have_link("Write") + end +end diff --git a/spec/features/projects/issues/user_sorts_issues_spec.rb b/spec/features/projects/issues/user_sorts_issues_spec.rb new file mode 100644 index 00000000000..34148ae0116 --- /dev/null +++ b/spec/features/projects/issues/user_sorts_issues_spec.rb @@ -0,0 +1,42 @@ +require "spec_helper" + +describe "User sorts issues" do + set(:project) { create(:project_empty_repo, :public) } + set(:issue1) { create(:issue, project: project) } + set(:issue2) { create(:issue, project: project) } + set(:issue3) { create(:issue, project: project) } + + before do + create_list(:award_emoji, 2, :upvote, awardable: issue1) + create_list(:award_emoji, 2, :downvote, awardable: issue2) + create(:award_emoji, :downvote, awardable: issue1) + create(:award_emoji, :upvote, awardable: issue2) + + visit(project_issues_path(project)) + end + + it "sorts by popularity" do + find("button.dropdown-toggle").click + + page.within(".content ul.dropdown-menu.dropdown-menu-align-right li") do + click_link("Popularity") + end + + page.within(".issues-list") do + page.within("li.issue:nth-child(1)") do + expect(page).to have_content(issue1.title) + expect(page).to have_content("2 1") + end + + page.within("li.issue:nth-child(2)") do + expect(page).to have_content(issue2.title) + expect(page).to have_content("1 2") + end + + page.within("li.issue:nth-child(3)") do + expect(page).to have_content(issue3.title) + expect(page).not_to have_content("0 0") + end + end + end +end diff --git a/spec/features/projects/issues/user_toggles_subscription_spec.rb b/spec/features/projects/issues/user_toggles_subscription_spec.rb new file mode 100644 index 00000000000..117a614b980 --- /dev/null +++ b/spec/features/projects/issues/user_toggles_subscription_spec.rb @@ -0,0 +1,28 @@ +require "spec_helper" + +describe "User toggles subscription", :js do + set(:project) { create(:project_empty_repo, :public) } + set(:user) { create(:user) } + set(:issue) { create(:issue, project: project, author: user) } + + before do + project.add_developer(user) + sign_in(user) + + visit(project_issue_path(project, issue)) + end + + it "unsibscribes from issue" do + subscription_button = find(".js-issuable-subscribe-button") + + # Check we're subscribed. + expect(subscription_button).to have_css("button.is-checked") + + # Toggle subscription. + find(".js-issuable-subscribe-button button").click + wait_for_requests + + # Check we're unsubscribed. + expect(subscription_button).to have_css("button:not(.is-checked)") + end +end diff --git a/spec/features/projects/issues/user_views_issue_spec.rb b/spec/features/projects/issues/user_views_issue_spec.rb new file mode 100644 index 00000000000..f7f2cde3d64 --- /dev/null +++ b/spec/features/projects/issues/user_views_issue_spec.rb @@ -0,0 +1,16 @@ +require "spec_helper" + +describe "User views issue" do + set(:project) { create(:project_empty_repo, :public) } + set(:user) { create(:user) } + set(:issue) { create(:issue, project: project, description: "# Description header", author: user) } + + before do + project.add_guest(user) + sign_in(user) + + visit(project_issue_path(project, issue)) + end + + it { expect(page).to have_header_with_correct_id_and_link(1, "Description header", "description-header") } +end diff --git a/spec/features/projects/issues/user_views_issues_spec.rb b/spec/features/projects/issues/user_views_issues_spec.rb index d35009b8974..58afb4efb86 100644 --- a/spec/features/projects/issues/user_views_issues_spec.rb +++ b/spec/features/projects/issues/user_views_issues_spec.rb @@ -1,56 +1,116 @@ -require 'spec_helper' +require "spec_helper" -describe 'User views issues' do +describe "User views issues" do + let!(:closed_issue) { create(:closed_issue, project: project) } + let!(:open_issue1) { create(:issue, project: project) } + let!(:open_issue2) { create(:issue, project: project) } set(:user) { create(:user) } - shared_examples_for 'shows issues' do - it 'shows issues' do - expect(page).to have_content(project.name) - .and have_content(issue1.title) - .and have_content(issue2.title) - .and have_no_selector('.js-new-board-list') + shared_examples "opens issue from list" do + it "opens issue" do + click_link(issue.title) + + expect(page).to have_content(issue.title) end end - context 'when project is public' do - set(:project) { create(:project_empty_repo, :public) } - set(:issue1) { create(:issue, project: project) } - set(:issue2) { create(:issue, project: project) } + shared_examples "open issues" do + context "open issues" do + let(:label) { create(:label, project: project, title: "bug") } - context 'when signed in' do before do - project.add_developer(user) - sign_in(user) + open_issue1.labels << label + + visit(project_issues_path(project, state: :opened)) + end - visit(project_issues_path(project)) + it "shows open issues" do + expect(page).to have_content(project.name) + .and have_content(open_issue1.title) + .and have_content(open_issue2.title) + .and have_no_content(closed_issue.title) + .and have_no_selector(".js-new-board-list") end - include_examples 'shows issues' + it "opens issues by label" do + page.within(".issues-list") do + click_link(label.title) + end + + expect(page).to have_content(open_issue1.title) + .and have_no_content(open_issue2.title) + .and have_no_content(closed_issue.title) + end + + include_examples "opens issue from list" do + let(:issue) { open_issue1 } + end end + end - context 'when not signed in' do + shared_examples "closed issues" do + context "closed issues" do before do - visit(project_issues_path(project)) + visit(project_issues_path(project, state: :closed)) + end + + it "shows closed issues" do + expect(page).to have_content(project.name) + .and have_content(closed_issue.title) + .and have_no_content(open_issue1.title) + .and have_no_content(open_issue2.title) + .and have_no_selector(".js-new-board-list") end - include_examples 'shows issues' + include_examples "opens issue from list" do + let(:issue) { closed_issue } + end end end - context 'when project is internal' do - set(:project) { create(:project_empty_repo, :internal) } - set(:issue1) { create(:issue, project: project) } - set(:issue2) { create(:issue, project: project) } - - context 'when signed in' do + shared_examples "all issues" do + context "all issues" do before do - project.add_developer(user) - sign_in(user) + visit(project_issues_path(project, state: :all)) + end - visit(project_issues_path(project)) + it "shows all issues" do + expect(page).to have_content(project.name) + .and have_content(closed_issue.title) + .and have_content(open_issue1.title) + .and have_content(open_issue2.title) + .and have_no_selector(".js-new-board-list") end - include_examples 'shows issues' + include_examples "opens issue from list" do + let(:issue) { closed_issue } + end + end + end + + %w[internal public].each do |visibility| + shared_examples "#{visibility} project" do + context "when project is #{visibility}" do + let(:project) { create(:project_empty_repo, :"#{visibility}") } + + include_examples "open issues" + include_examples "closed issues" + include_examples "all issues" + end end end + + context "when signed in as developer" do + before do + project.add_developer(user) + sign_in(user) + end + + include_examples "public project" + include_examples "internal project" + end + + context "when not signed in" do + include_examples "public project" + end end diff --git a/spec/features/projects/labels/user_creates_labels_spec.rb b/spec/features/projects/labels/user_creates_labels_spec.rb new file mode 100644 index 00000000000..9fd7f3ee775 --- /dev/null +++ b/spec/features/projects/labels/user_creates_labels_spec.rb @@ -0,0 +1,88 @@ +require "spec_helper" + +describe "User creates labels" do + set(:project) { create(:project_empty_repo, :public) } + set(:user) { create(:user) } + + shared_examples_for "label creation" do + it "creates new label" do + title = "bug" + + create_label(title) + + page.within(".other-labels .manage-labels-list") do + expect(page).to have_content(title) + end + end + end + + context "in project" do + before do + project.add_master(user) + sign_in(user) + + visit(new_project_label_path(project)) + end + + context "when data is valid" do + include_examples "label creation" + end + + context "when data is invalid" do + context "when title is invalid" do + it "shows error message" do + create_label("") + + page.within(".label-form") do + expect(page).to have_content("Title can't be blank") + end + end + end + + context "when color is invalid" do + it "shows error message" do + create_label("feature", "#12") + + page.within(".label-form") do + expect(page).to have_content("Color must be a valid color code") + end + end + end + end + + context "when label already exists" do + let!(:label) { create(:label, project: project) } + + it "shows error message" do + create_label(label.title) + + page.within(".label-form") do + expect(page).to have_content("Title has already been taken") + end + end + end + end + + context "in another project" do + set(:another_project) { create(:project_empty_repo, :public) } + + before do + create(:label, project: project, title: "bug") # Create label for `project` (not `another_project`) project. + + another_project.add_master(user) + sign_in(user) + + visit(new_project_label_path(another_project)) + end + + include_examples "label creation" + end + + private + + def create_label(title, color = "#F95610") + fill_in("Title", with: title) + fill_in("Background color", with: color) + click_button("Create label") + end +end diff --git a/spec/features/projects/labels/user_edits_labels_spec.rb b/spec/features/projects/labels/user_edits_labels_spec.rb new file mode 100644 index 00000000000..d1041ff5c1e --- /dev/null +++ b/spec/features/projects/labels/user_edits_labels_spec.rb @@ -0,0 +1,25 @@ +require "spec_helper" + +describe "User edits labels" do + set(:project) { create(:project_empty_repo, :public) } + set(:label) { create(:label, project: project) } + set(:user) { create(:user) } + + before do + project.add_master(user) + sign_in(user) + + visit(edit_project_label_path(project, label)) + end + + it "updates label's title" do + new_title = "fix" + + fill_in("Title", with: new_title) + click_button("Save changes") + + page.within(".other-labels .manage-labels-list") do + expect(page).to have_content(new_title).and have_no_content(label.title) + end + end +end diff --git a/spec/features/projects/labels/user_removes_labels_spec.rb b/spec/features/projects/labels/user_removes_labels_spec.rb new file mode 100644 index 00000000000..f4fda6de465 --- /dev/null +++ b/spec/features/projects/labels/user_removes_labels_spec.rb @@ -0,0 +1,52 @@ +require "spec_helper" + +describe "User removes labels" do + let(:project) { create(:project_empty_repo, :public) } + let(:user) { create(:user) } + + before do + project.add_master(user) + sign_in(user) + end + + context "when one label" do + let!(:label) { create(:label, project: project) } + + before do + visit(project_labels_path(project)) + end + + it "removes label" do + page.within(".labels") do + page.first(".label-list-item") do + first(".remove-row").click + first(:link, "Delete label").click + end + end + + expect(page).to have_content("Label was removed").and have_no_content(label.title) + end + end + + context "when many labels", :js do + before do + create_list(:label, 3, project: project) + + visit(project_labels_path(project)) + end + + it "removes all labels" do + page.within(".labels") do + loop do + li = page.first(".label-list-item") + break unless li + + li.click_link("Delete") + click_link("Delete label") + end + + expect(page).to have_content("Generate a default set of labels").and have_content("New label") + end + end + end +end diff --git a/spec/features/projects/labels/user_views_labels_spec.rb b/spec/features/projects/labels/user_views_labels_spec.rb new file mode 100644 index 00000000000..0cbeca4e392 --- /dev/null +++ b/spec/features/projects/labels/user_views_labels_spec.rb @@ -0,0 +1,23 @@ +require "spec_helper" + +describe "User views labels" do + set(:project) { create(:project_empty_repo, :public) } + set(:user) { create(:user) } + + LABEL_TITLES = %w[bug enhancement feature].freeze + + before do + LABEL_TITLES.each { |title| create(:label, project: project, title: title) } + + project.add_guest(user) + sign_in(user) + + visit(project_labels_path(project)) + end + + it "shows all labels" do + page.within('.other-labels .manage-labels-list') do + LABEL_TITLES.each { |title| expect(page).to have_content(title) } + end + end +end diff --git a/spec/features/projects/milestones/milestones_sorting_spec.rb b/spec/features/projects/milestones/milestones_sorting_spec.rb index c531b81e04d..b64786d4eec 100644 --- a/spec/features/projects/milestones/milestones_sorting_spec.rb +++ b/spec/features/projects/milestones/milestones_sorting_spec.rb @@ -1,7 +1,6 @@ require 'spec_helper' feature 'Milestones sorting', :js do - include SortingHelper let(:user) { create(:user) } let(:project) { create(:project, name: 'test', namespace: user.namespace) } diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index a4084818284..43cabd3b9f2 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -142,7 +142,10 @@ feature 'Protected Branches', :js do set_protected_branch_name('*-stable') click_on "Protect" - within(".protected-branches-list") { expect(page).to have_content("2 matching branches") } + within(".protected-branches-list") do + expect(page).to have_content("Protected branch (2)") + expect(page).to have_content("2 matching branches") + end end it "displays all the branches matching the wildcard" do diff --git a/spec/features/protected_tags_spec.rb b/spec/features/protected_tags_spec.rb index 8cc6f17b8d9..efccaeaff6c 100644 --- a/spec/features/protected_tags_spec.rb +++ b/spec/features/protected_tags_spec.rb @@ -65,7 +65,10 @@ feature 'Protected Tags', :js do set_protected_tag_name('*-stable') click_on "Protect" - within(".protected-tags-list") { expect(page).to have_content("2 matching tags") } + within(".protected-tags-list") do + expect(page).to have_content("Protected tag (2)") + expect(page).to have_content("2 matching tags") + end end it "displays all the tags matching the wildcard" do diff --git a/spec/features/user_sorts_things_spec.rb b/spec/features/user_sorts_things_spec.rb new file mode 100644 index 00000000000..69ebdddaeec --- /dev/null +++ b/spec/features/user_sorts_things_spec.rb @@ -0,0 +1,57 @@ +require "spec_helper" + +# The main goal of this spec is not to check whether the sorting UI works, but +# to check if the sorting option set by user is being kept persisted while going through pages. +# The `it`s are named here by convention `starting point -> some pages -> final point`. +# All those specs are moved out to this spec intentionally to keep them all in one place. +describe "User sorts things" do + include Spec::Support::Helpers::Features::SortingHelpers + include Helpers::DashboardHelper + + set(:project) { create(:project_empty_repo, :public) } + set(:current_user) { create(:user) } # Using `current_user` instead of just `user` because of the hardoced call in `assigned_mrs_dashboard_path` which is used below. + set(:issue) { create(:issue, project: project, author: current_user) } + set(:merge_request) { create(:merge_request, target_project: project, source_project: project, author: current_user) } + + before do + project.add_developer(current_user) + sign_in(current_user) + end + + it "issues -> project home page -> issues" do + sort_option = "Last updated" + + visit(project_issues_path(project)) + + sort_by(sort_option) + + visit(project_path(project)) + visit(project_issues_path(project)) + + expect(find(".issues-filters")).to have_content(sort_option) + end + + it "issues -> merge requests" do + sort_option = "Last updated" + + visit(project_issues_path(project)) + + sort_by(sort_option) + + visit(project_merge_requests_path(project)) + + expect(find(".issues-filters")).to have_content(sort_option) + end + + it "merge requests -> dashboard merge requests" do + sort_option = "Last updated" + + visit(project_merge_requests_path(project)) + + sort_by(sort_option) + + visit(assigned_mrs_dashboard_path) + + expect(find(".issues-filters")).to have_content(sort_option) + end +end diff --git a/spec/fixtures/api/schemas/public_api/v4/project/export_status.json b/spec/fixtures/api/schemas/public_api/v4/project/export_status.json index d24a6f93f4b..81c8815caf6 100644 --- a/spec/fixtures/api/schemas/public_api/v4/project/export_status.json +++ b/spec/fixtures/api/schemas/public_api/v4/project/export_status.json @@ -1,7 +1,9 @@ { "type": "object", "allOf": [ - { "$ref": "identity.json" }, + { + "$ref": "identity.json" + }, { "required": [ "export_status" @@ -9,7 +11,12 @@ "properties": { "export_status": { "type": "string", - "enum": ["none", "started", "finished"] + "enum": [ + "none", + "started", + "finished", + "after_export_action" + ] } } } diff --git a/spec/helpers/page_layout_helper_spec.rb b/spec/helpers/page_layout_helper_spec.rb index baf927a9acc..b77114a8152 100644 --- a/spec/helpers/page_layout_helper_spec.rb +++ b/spec/helpers/page_layout_helper_spec.rb @@ -50,6 +50,11 @@ describe PageLayoutHelper do allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development')) expect(helper.favicon).to eq 'favicon-blue.ico' end + + it 'has yellow favicon for canary' do + stub_env('CANARY', 'true') + expect(helper.favicon).to eq 'favicon-yellow.ico' + end end describe 'page_image' do diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 5477581c1b9..3d7ccf432be 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -35,14 +35,14 @@ describe('Api', () => { }); describe('group', () => { - it('fetches a group', (done) => { + it('fetches a group', done => { const groupId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}`; mock.onGet(expectedUrl).reply(200, { name: 'test', }); - Api.group(groupId, (response) => { + Api.group(groupId, response => { expect(response.name).toBe('test'); done(); }); @@ -50,15 +50,17 @@ describe('Api', () => { }); describe('groups', () => { - it('fetches groups', (done) => { + it('fetches groups', done => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups.json`; - mock.onGet(expectedUrl).reply(200, [{ - name: 'test', - }]); + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); - Api.groups(query, options, (response) => { + Api.groups(query, options, response => { expect(response.length).toBe(1); expect(response[0].name).toBe('test'); done(); @@ -67,14 +69,16 @@ describe('Api', () => { }); describe('namespaces', () => { - it('fetches namespaces', (done) => { + it('fetches namespaces', done => { const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/namespaces.json`; - mock.onGet(expectedUrl).reply(200, [{ - name: 'test', - }]); + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); - Api.namespaces(query, (response) => { + Api.namespaces(query, response => { expect(response.length).toBe(1); expect(response[0].name).toBe('test'); done(); @@ -83,31 +87,35 @@ describe('Api', () => { }); describe('projects', () => { - it('fetches projects with membership when logged in', (done) => { + it('fetches projects with membership when logged in', done => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; window.gon.current_user_id = 1; - mock.onGet(expectedUrl).reply(200, [{ - name: 'test', - }]); + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); - Api.projects(query, options, (response) => { + Api.projects(query, options, response => { expect(response.length).toBe(1); expect(response[0].name).toBe('test'); done(); }); }); - it('fetches projects without membership when not logged in', (done) => { + it('fetches projects without membership when not logged in', done => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; - mock.onGet(expectedUrl).reply(200, [{ - name: 'test', - }]); + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); - Api.projects(query, options, (response) => { + Api.projects(query, options, response => { expect(response.length).toBe(1); expect(response[0].name).toBe('test'); done(); @@ -115,8 +123,65 @@ describe('Api', () => { }); }); + describe('mergerequest', () => { + it('fetches a merge request', done => { + const projectPath = 'abc'; + const mergeRequestId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}`; + mock.onGet(expectedUrl).reply(200, { + title: 'test', + }); + + Api.mergeRequest(projectPath, mergeRequestId) + .then(({ data }) => { + expect(data.title).toBe('test'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('mergerequest changes', () => { + it('fetches the changes of a merge request', done => { + const projectPath = 'abc'; + const mergeRequestId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/changes`; + mock.onGet(expectedUrl).reply(200, { + title: 'test', + }); + + Api.mergeRequestChanges(projectPath, mergeRequestId) + .then(({ data }) => { + expect(data.title).toBe('test'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('mergerequest versions', () => { + it('fetches the versions of a merge request', done => { + const projectPath = 'abc'; + const mergeRequestId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/versions`; + mock.onGet(expectedUrl).reply(200, [ + { + id: 123, + }, + ]); + + Api.mergeRequestVersions(projectPath, mergeRequestId) + .then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].id).toBe(123); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('newLabel', () => { - it('creates a new label', (done) => { + it('creates a new label', done => { const namespace = 'some namespace'; const project = 'some project'; const labelData = { some: 'data' }; @@ -124,36 +189,42 @@ describe('Api', () => { const expectedData = { label: labelData, }; - mock.onPost(expectedUrl).reply((config) => { + mock.onPost(expectedUrl).reply(config => { expect(config.data).toBe(JSON.stringify(expectedData)); - return [200, { - name: 'test', - }]; + return [ + 200, + { + name: 'test', + }, + ]; }); - Api.newLabel(namespace, project, labelData, (response) => { + Api.newLabel(namespace, project, labelData, response => { expect(response.name).toBe('test'); done(); }); }); - it('creates a group label', (done) => { + it('creates a group label', done => { const namespace = 'group/subgroup'; const labelData = { some: 'data' }; const expectedUrl = `${dummyUrlRoot}/groups/${namespace}/-/labels`; const expectedData = { label: labelData, }; - mock.onPost(expectedUrl).reply((config) => { + mock.onPost(expectedUrl).reply(config => { expect(config.data).toBe(JSON.stringify(expectedData)); - return [200, { - name: 'test', - }]; + return [ + 200, + { + name: 'test', + }, + ]; }); - Api.newLabel(namespace, undefined, labelData, (response) => { + Api.newLabel(namespace, undefined, labelData, response => { expect(response.name).toBe('test'); done(); }); @@ -161,15 +232,17 @@ describe('Api', () => { }); describe('groupProjects', () => { - it('fetches group projects', (done) => { + it('fetches group projects', done => { const groupId = '123456'; const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; - mock.onGet(expectedUrl).reply(200, [{ - name: 'test', - }]); + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); - Api.groupProjects(groupId, query, (response) => { + Api.groupProjects(groupId, query, response => { expect(response.length).toBe(1); expect(response[0].name).toBe('test'); done(); @@ -178,13 +251,13 @@ describe('Api', () => { }); describe('licenseText', () => { - it('fetches a license text', (done) => { + it('fetches a license text', done => { const licenseKey = "driver's license"; const data = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/licenses/${licenseKey}`; mock.onGet(expectedUrl).reply(200, 'test'); - Api.licenseText(licenseKey, data, (response) => { + Api.licenseText(licenseKey, data, response => { expect(response).toBe('test'); done(); }); @@ -192,12 +265,12 @@ describe('Api', () => { }); describe('gitignoreText', () => { - it('fetches a gitignore text', (done) => { + it('fetches a gitignore text', done => { const gitignoreKey = 'ignore git'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/gitignores/${gitignoreKey}`; mock.onGet(expectedUrl).reply(200, 'test'); - Api.gitignoreText(gitignoreKey, (response) => { + Api.gitignoreText(gitignoreKey, response => { expect(response).toBe('test'); done(); }); @@ -205,12 +278,12 @@ describe('Api', () => { }); describe('gitlabCiYml', () => { - it('fetches a .gitlab-ci.yml', (done) => { + it('fetches a .gitlab-ci.yml', done => { const gitlabCiYmlKey = 'Y CI ML'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/gitlab_ci_ymls/${gitlabCiYmlKey}`; mock.onGet(expectedUrl).reply(200, 'test'); - Api.gitlabCiYml(gitlabCiYmlKey, (response) => { + Api.gitlabCiYml(gitlabCiYmlKey, response => { expect(response).toBe('test'); done(); }); @@ -218,12 +291,12 @@ describe('Api', () => { }); describe('dockerfileYml', () => { - it('fetches a Dockerfile', (done) => { + it('fetches a Dockerfile', done => { const dockerfileYmlKey = 'a giant whale'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/templates/dockerfiles/${dockerfileYmlKey}`; mock.onGet(expectedUrl).reply(200, 'test'); - Api.dockerfileYml(dockerfileYmlKey, (response) => { + Api.dockerfileYml(dockerfileYmlKey, response => { expect(response).toBe('test'); done(); }); @@ -231,12 +304,14 @@ describe('Api', () => { }); describe('issueTemplate', () => { - it('fetches an issue template', (done) => { + 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}/${encodeURIComponent(templateKey)}`; + const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}/${encodeURIComponent( + templateKey, + )}`; mock.onGet(expectedUrl).reply(200, 'test'); Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => { @@ -247,13 +322,15 @@ describe('Api', () => { }); describe('users', () => { - it('fetches users', (done) => { + it('fetches users', done => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users.json`; - mock.onGet(expectedUrl).reply(200, [{ - name: 'test', - }]); + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); Api.users(query, options) .then(({ data }) => { diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js index 0671facb285..81f1a97112f 100644 --- a/spec/javascripts/boards/mock_data.js +++ b/spec/javascripts/boards/mock_data.js @@ -1,7 +1,4 @@ /* global BoardService */ -/* eslint-disable comma-dangle, no-unused-vars, quote-props */ -import _ from 'underscore'; - export const listObj = { id: 300, position: 0, @@ -11,8 +8,8 @@ export const listObj = { id: 5000, title: 'Testing', color: 'red', - description: 'testing;' - } + description: 'testing;', + }, }; export const listObjDuplicate = { @@ -24,35 +21,37 @@ export const listObjDuplicate = { id: listObj.label.id, title: 'Testing', color: 'red', - description: 'testing;' - } + description: 'testing;', + }, }; export const BoardsMockData = { - 'GET': { + GET: { '/test/-/boards/1/lists/300/issues?id=300&page=1&=': { - issues: [{ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [], - assignees: [], - }], - } + issues: [ + { + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [], + assignees: [], + }, + ], + }, + }, + POST: { + '/test/-/boards/1/lists': listObj, }, - 'POST': { - '/test/-/boards/1/lists': listObj + PUT: { + '/test/issue-boards/board/1/lists{/id}': {}, }, - 'PUT': { - '/test/issue-boards/board/1/lists{/id}': {} + DELETE: { + '/test/issue-boards/board/1/lists{/id}': {}, }, - 'DELETE': { - '/test/issue-boards/board/1/lists{/id}': {} - } }; -export const boardsMockInterceptor = (config) => { +export const boardsMockInterceptor = config => { const body = BoardsMockData[config.method.toUpperCase()][config.url]; return [200, body]; }; diff --git a/spec/javascripts/droplab/constants_spec.js b/spec/javascripts/droplab/constants_spec.js index b9d28db74cc..23b69defec6 100644 --- a/spec/javascripts/droplab/constants_spec.js +++ b/spec/javascripts/droplab/constants_spec.js @@ -1,39 +1,37 @@ -/* eslint-disable */ - import * as constants from '~/droplab/constants'; -describe('constants', function () { - describe('DATA_TRIGGER', function () { +describe('constants', function() { + describe('DATA_TRIGGER', function() { it('should be `data-dropdown-trigger`', function() { expect(constants.DATA_TRIGGER).toBe('data-dropdown-trigger'); }); }); - describe('DATA_DROPDOWN', function () { + describe('DATA_DROPDOWN', function() { it('should be `data-dropdown`', function() { expect(constants.DATA_DROPDOWN).toBe('data-dropdown'); }); }); - describe('SELECTED_CLASS', function () { + describe('SELECTED_CLASS', function() { it('should be `droplab-item-selected`', function() { expect(constants.SELECTED_CLASS).toBe('droplab-item-selected'); }); }); - describe('ACTIVE_CLASS', function () { + describe('ACTIVE_CLASS', function() { it('should be `droplab-item-active`', function() { expect(constants.ACTIVE_CLASS).toBe('droplab-item-active'); }); }); - describe('TEMPLATE_REGEX', function () { + describe('TEMPLATE_REGEX', function() { it('should be a handlebars templating syntax regex', function() { expect(constants.TEMPLATE_REGEX).toEqual(/\{\{(.+?)\}\}/g); }); }); - describe('IGNORE_CLASS', function () { + describe('IGNORE_CLASS', function() { it('should be `droplab-item-ignore`', function() { expect(constants.IGNORE_CLASS).toBe('droplab-item-ignore'); }); diff --git a/spec/javascripts/fixtures/projects.rb b/spec/javascripts/fixtures/projects.rb index b344b389241..e8865b04874 100644 --- a/spec/javascripts/fixtures/projects.rb +++ b/spec/javascripts/fixtures/projects.rb @@ -17,8 +17,6 @@ describe 'Projects (JavaScript fixtures)', type: :controller do end before do - # EE-specific start - # EE specific end project.add_master(admin) sign_in(admin) end diff --git a/spec/javascripts/gfm_auto_complete_spec.js b/spec/javascripts/gfm_auto_complete_spec.js index dc0a5bc275c..1cb20a1e7ff 100644 --- a/spec/javascripts/gfm_auto_complete_spec.js +++ b/spec/javascripts/gfm_auto_complete_spec.js @@ -81,13 +81,21 @@ describe('GfmAutoComplete', function () { }); it('should quote if value contains any non-alphanumeric characters', () => { - expect(beforeInsert(atwhoInstance, '~label-20')).toBe('~"label-20"'); + expect(beforeInsert(atwhoInstance, '~label-20')).toBe('~"label\\-20"'); expect(beforeInsert(atwhoInstance, '~label 20')).toBe('~"label 20"'); }); it('should quote integer labels', () => { expect(beforeInsert(atwhoInstance, '~1234')).toBe('~"1234"'); }); + + it('should escape Markdown emphasis characters, except in the first character', () => { + expect(beforeInsert(atwhoInstance, '@_group')).toEqual('@\\_group'); + expect(beforeInsert(atwhoInstance, '~_bug')).toEqual('~\\_bug'); + expect(beforeInsert(atwhoInstance, '~a `bug`')).toEqual('~"a \\`bug\\`"'); + expect(beforeInsert(atwhoInstance, '~a ~bug')).toEqual('~"a \\~bug"'); + expect(beforeInsert(atwhoInstance, '~a **bug')).toEqual('~"a \\*\\*bug"'); + }); }); describe('DefaultOptions.matcher', function () { diff --git a/spec/javascripts/helpers/vuex_action_helper.js b/spec/javascripts/helpers/vuex_action_helper.js index 2d386fe1da5..83f29d1b0c2 100644 --- a/spec/javascripts/helpers/vuex_action_helper.js +++ b/spec/javascripts/helpers/vuex_action_helper.js @@ -1,37 +1,71 @@ -/* eslint-disable */ - /** - * helper for testing action with expected mutations + * helper for testing action with expected mutations inspired in * https://vuex.vuejs.org/en/testing.html + * + * @example + * testAction( + * actions.actionName, // action + * { }, // mocked response + * state, // state + * [ + * { type: types.MUTATION} + * { type: types.MUTATION_1, payload: {}} + * ], // mutations + * [ + * { type: 'actionName', payload: {}}, + * { type: 'actionName1', payload: {}} + * ] //actions + * done, + * ); */ -export default (action, payload, state, expectedMutations, done) => { - let count = 0; +export default (action, payload, state, expectedMutations, expectedActions, done) => { + let mutationsCount = 0; + let actionsCount = 0; // mock commit - const commit = (type, payload) => { - const mutation = expectedMutations[count]; - - try { - expect(mutation.type).to.equal(type); - if (payload) { - expect(mutation.payload).to.deep.equal(payload); - } - } catch (error) { - done(error); + const commit = (type, mutationPayload) => { + const mutation = expectedMutations[mutationsCount]; + + expect(mutation.type).toEqual(type); + + if (mutation.payload) { + expect(mutation.payload).toEqual(mutationPayload); } - count++; - if (count >= expectedMutations.length) { + mutationsCount += 1; + if (mutationsCount >= expectedMutations.length) { + done(); + } + }; + + // mock dispatch + const dispatch = (type, actionPayload) => { + const actionExpected = expectedActions[actionsCount]; + + expect(actionExpected.type).toEqual(type); + + if (actionExpected.payload) { + expect(actionExpected.payload).toEqual(actionPayload); + } + + actionsCount += 1; + if (actionsCount >= expectedActions.length) { done(); } }; // call the action with mocked store and arguments - action({ commit, state }, payload); + action({ commit, state, dispatch }, payload); // check if no mutations should have been dispatched if (expectedMutations.length === 0) { - expect(count).to.equal(0); + expect(mutationsCount).toEqual(0); + done(); + } + + // check if no mutations should have been dispatched + if (expectedActions.length === 0) { + expect(actionsCount).toEqual(0); done(); } }; diff --git a/spec/javascripts/ide/components/changed_file_icon_spec.js b/spec/javascripts/ide/components/changed_file_icon_spec.js index 987aea7befc..541864e912e 100644 --- a/spec/javascripts/ide/components/changed_file_icon_spec.js +++ b/spec/javascripts/ide/components/changed_file_icon_spec.js @@ -11,6 +11,7 @@ describe('IDE changed file icon', () => { vm = createComponent(component, { file: { tempFile: false, + changed: true, }, }); }); @@ -20,7 +21,7 @@ describe('IDE changed file icon', () => { }); describe('changedIcon', () => { - it('equals file-modified when not a temp file', () => { + it('equals file-modified when not a temp file and has changes', () => { expect(vm.changedIcon).toBe('file-modified'); }); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js index 15b66952d99..509434e4300 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -1,8 +1,9 @@ import Vue from 'vue'; import listItem from '~/ide/components/commit_sidebar/list_item.vue'; import router from '~/ide/ide_router'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { file } from '../../helpers'; +import store from '~/ide/stores'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file, resetStore } from '../../helpers'; describe('Multi-file editor commit sidebar list item', () => { let vm; @@ -13,19 +14,21 @@ describe('Multi-file editor commit sidebar list item', () => { f = file('test-file'); - vm = mountComponent(Component, { + store.state.entries[f.path] = f; + + vm = createComponentWithStore(Component, store, { file: f, - }); + }).$mount(); }); afterEach(() => { vm.$destroy(); + + resetStore(store); }); it('renders file path', () => { - expect( - vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim(), - ).toBe(f.path); + expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path); }); it('calls discardFileChanges when clicking discard button', () => { @@ -36,25 +39,32 @@ describe('Multi-file editor commit sidebar list item', () => { expect(vm.discardFileChanges).toHaveBeenCalled(); }); - it('opens a closed file in the editor when clicking the file path', () => { + it('opens a closed file in the editor when clicking the file path', done => { spyOn(vm, 'openFileInEditor').and.callThrough(); - spyOn(vm, 'updateViewer'); spyOn(router, 'push'); vm.$el.querySelector('.multi-file-commit-list-path').click(); - expect(vm.openFileInEditor).toHaveBeenCalled(); - expect(router.push).toHaveBeenCalled(); + setTimeout(() => { + expect(vm.openFileInEditor).toHaveBeenCalled(); + expect(router.push).toHaveBeenCalled(); + + done(); + }); }); - it('calls updateViewer with diff when clicking file', () => { + it('calls updateViewer with diff when clicking file', done => { spyOn(vm, 'openFileInEditor').and.callThrough(); - spyOn(vm, 'updateViewer'); + spyOn(vm, 'updateViewer').and.callThrough(); spyOn(router, 'push'); vm.$el.querySelector('.multi-file-commit-list-path').click(); - expect(vm.updateViewer).toHaveBeenCalledWith('diff'); + setTimeout(() => { + expect(vm.updateViewer).toHaveBeenCalledWith('diff'); + + done(); + }); }); describe('computed', () => { diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js index ae657e8c881..9d3fa1280f4 100644 --- a/spec/javascripts/ide/components/repo_editor_spec.js +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -89,6 +89,20 @@ describe('RepoEditor', () => { done(); }); }); + + it('calls createDiffInstance when viewer is a merge request diff', done => { + vm.$store.state.viewer = 'mrdiff'; + + spyOn(vm.editor, 'createDiffInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + + done(); + }); + }); }); describe('setupEditor', () => { @@ -134,4 +148,48 @@ describe('RepoEditor', () => { }); }); }); + + describe('setup editor for merge request viewing', () => { + beforeEach(done => { + // Resetting as the main test setup has already done it + vm.$destroy(); + resetStore(vm.$store); + Editor.editorInstance.modelManager.dispose(); + + const f = { + ...file(), + active: true, + tempFile: true, + html: 'testing', + mrChange: { diff: 'ABC' }, + baseRaw: 'testing', + content: 'test', + }; + const RepoEditor = Vue.extend(repoEditor); + vm = createComponentWithStore(RepoEditor, store, { + file: f, + }); + + vm.$store.state.openFiles.push(f); + vm.$store.state.entries[f.path] = f; + + vm.$store.state.viewer = 'mrdiff'; + + vm.monaco = true; + + vm.$mount(); + + monacoLoader(['vs/editor/editor.main'], () => { + setTimeout(done, 0); + }); + }); + + it('attaches merge request model to editor when merge request diff', () => { + spyOn(vm.editor, 'attachMergeRequestModel').and.callThrough(); + + vm.setupEditor(); + + expect(vm.editor.attachMergeRequestModel).toHaveBeenCalledWith(vm.model); + }); + }); }); diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js index ddb5204e3a7..8cabc6e8935 100644 --- a/spec/javascripts/ide/components/repo_tab_spec.js +++ b/spec/javascripts/ide/components/repo_tab_spec.js @@ -59,7 +59,7 @@ describe('RepoTab', () => { vm.$el.querySelector('.multi-file-tab-close').click(); - expect(vm.closeFile).toHaveBeenCalledWith(vm.tab.path); + expect(vm.closeFile).toHaveBeenCalledWith(vm.tab); }); it('changes icon on hover', done => { diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js index ceb0416aff8..cb785ba2cd3 100644 --- a/spec/javascripts/ide/components/repo_tabs_spec.js +++ b/spec/javascripts/ide/components/repo_tabs_spec.js @@ -17,6 +17,8 @@ describe('RepoTabs', () => { files: openedFiles, viewer: 'editor', hasChanges: false, + activeFile: file('activeFile'), + hasMergeRequest: false, }); openedFiles[0].active = true; @@ -56,6 +58,8 @@ describe('RepoTabs', () => { files: [], viewer: 'editor', hasChanges: false, + activeFile: file('activeFile'), + hasMergeRequest: false, }, '#test-app', ); diff --git a/spec/javascripts/ide/lib/common/model_manager_spec.js b/spec/javascripts/ide/lib/common/model_manager_spec.js index 4381f6fcfd0..c00d590c580 100644 --- a/spec/javascripts/ide/lib/common/model_manager_spec.js +++ b/spec/javascripts/ide/lib/common/model_manager_spec.js @@ -27,9 +27,10 @@ describe('Multi-file editor library model manager', () => { }); it('caches model by file path', () => { - instance.addModel(file('path-name')); + const f = file('path-name'); + instance.addModel(f); - expect(instance.models.keys().next().value).toBe('path-name'); + expect(instance.models.keys().next().value).toBe(f.key); }); it('adds model into disposable', () => { @@ -56,7 +57,7 @@ describe('Multi-file editor library model manager', () => { instance.addModel(f); expect(eventHub.$on).toHaveBeenCalledWith( - `editor.update.model.dispose.${f.path}`, + `editor.update.model.dispose.${f.key}`, jasmine.anything(), ); }); @@ -68,9 +69,11 @@ describe('Multi-file editor library model manager', () => { }); it('returns true when model exists', () => { - instance.addModel(file('path-name')); + const f = file('path-name'); + + instance.addModel(f); - expect(instance.hasCachedModel('path-name')).toBeTruthy(); + expect(instance.hasCachedModel(f.key)).toBeTruthy(); }); }); @@ -103,7 +106,7 @@ describe('Multi-file editor library model manager', () => { instance.removeCachedModel(f); expect(eventHub.$off).toHaveBeenCalledWith( - `editor.update.model.dispose.${f.path}`, + `editor.update.model.dispose.${f.key}`, jasmine.anything(), ); }); diff --git a/spec/javascripts/ide/lib/common/model_spec.js b/spec/javascripts/ide/lib/common/model_spec.js index adc6a93c06b..8fc2fccb64c 100644 --- a/spec/javascripts/ide/lib/common/model_spec.js +++ b/spec/javascripts/ide/lib/common/model_spec.js @@ -11,7 +11,10 @@ describe('Multi-file editor library model', () => { spyOn(eventHub, '$on').and.callThrough(); monacoLoader(['vs/editor/editor.main'], () => { - model = new Model(monaco, file('path')); + const f = file('path'); + f.mrChange = { diff: 'ABC' }; + f.baseRaw = 'test'; + model = new Model(monaco, f); done(); }); @@ -21,21 +24,22 @@ describe('Multi-file editor library model', () => { model.dispose(); }); - it('creates original model & new model', () => { + it('creates original model & base model & new model', () => { expect(model.originalModel).not.toBeNull(); expect(model.model).not.toBeNull(); + expect(model.baseModel).not.toBeNull(); }); it('adds eventHub listener', () => { expect(eventHub.$on).toHaveBeenCalledWith( - `editor.update.model.dispose.${model.file.path}`, + `editor.update.model.dispose.${model.file.key}`, jasmine.anything(), ); }); describe('path', () => { it('returns file path', () => { - expect(model.path).toBe('path'); + expect(model.path).toBe(model.file.key); }); }); @@ -51,6 +55,12 @@ describe('Multi-file editor library model', () => { }); }); + describe('getBaseModel', () => { + it('returns base model', () => { + expect(model.getBaseModel()).toBe(model.baseModel); + }); + }); + describe('setValue', () => { it('updates models value', () => { model.setValue('testing 123'); @@ -64,7 +74,7 @@ describe('Multi-file editor library model', () => { model.onChange(() => {}); expect(model.events.size).toBe(1); - expect(model.events.keys().next().value).toBe('path'); + expect(model.events.keys().next().value).toBe(model.file.key); }); it('calls callback on change', done => { @@ -105,7 +115,7 @@ describe('Multi-file editor library model', () => { model.dispose(); expect(eventHub.$off).toHaveBeenCalledWith( - `editor.update.model.dispose.${model.file.path}`, + `editor.update.model.dispose.${model.file.key}`, jasmine.anything(), ); }); diff --git a/spec/javascripts/ide/lib/decorations/controller_spec.js b/spec/javascripts/ide/lib/decorations/controller_spec.js index 092170d086a..aec325e26a9 100644 --- a/spec/javascripts/ide/lib/decorations/controller_spec.js +++ b/spec/javascripts/ide/lib/decorations/controller_spec.js @@ -36,9 +36,7 @@ describe('Multi-file editor library decorations controller', () => { }); it('returns decorations by model URL', () => { - controller.addDecorations(model, 'key', [ - { decoration: 'decorationValue' }, - ]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); const decorations = controller.getAllDecorationsForModel(model); @@ -48,39 +46,29 @@ describe('Multi-file editor library decorations controller', () => { describe('addDecorations', () => { it('caches decorations in a new map', () => { - controller.addDecorations(model, 'key', [ - { decoration: 'decorationValue' }, - ]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); expect(controller.decorations.size).toBe(1); }); it('does not create new cache model', () => { - controller.addDecorations(model, 'key', [ - { decoration: 'decorationValue' }, - ]); - controller.addDecorations(model, 'key', [ - { decoration: 'decorationValue2' }, - ]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]); expect(controller.decorations.size).toBe(1); }); it('caches decorations by model URL', () => { - controller.addDecorations(model, 'key', [ - { decoration: 'decorationValue' }, - ]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); expect(controller.decorations.size).toBe(1); - expect(controller.decorations.keys().next().value).toBe('path'); + expect(controller.decorations.keys().next().value).toBe('path--path'); }); it('calls decorate method', () => { spyOn(controller, 'decorate'); - controller.addDecorations(model, 'key', [ - { decoration: 'decorationValue' }, - ]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); expect(controller.decorate).toHaveBeenCalled(); }); @@ -92,10 +80,7 @@ describe('Multi-file editor library decorations controller', () => { controller.decorate(model); - expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith( - [], - [], - ); + expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []); }); it('caches decorations', () => { @@ -111,15 +96,13 @@ describe('Multi-file editor library decorations controller', () => { controller.decorate(model); - expect(controller.editorDecorations.keys().next().value).toBe('path'); + expect(controller.editorDecorations.keys().next().value).toBe('path--path'); }); }); describe('dispose', () => { it('clears cached decorations', () => { - controller.addDecorations(model, 'key', [ - { decoration: 'decorationValue' }, - ]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); controller.dispose(); @@ -127,9 +110,7 @@ describe('Multi-file editor library decorations controller', () => { }); it('clears cached editorDecorations', () => { - controller.addDecorations(model, 'key', [ - { decoration: 'decorationValue' }, - ]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); controller.dispose(); diff --git a/spec/javascripts/ide/lib/diff/controller_spec.js b/spec/javascripts/ide/lib/diff/controller_spec.js index c8f3e9f4830..ff73240734e 100644 --- a/spec/javascripts/ide/lib/diff/controller_spec.js +++ b/spec/javascripts/ide/lib/diff/controller_spec.js @@ -131,7 +131,7 @@ describe('Multi-file editor library dirty diff controller', () => { it('adds decorations into decorations controller', () => { spyOn(controller.decorationsController, 'addDecorations'); - controller.decorate({ data: { changes: [], path: 'path' } }); + controller.decorate({ data: { changes: [], path: model.path } }); expect( controller.decorationsController.addDecorations, @@ -145,7 +145,7 @@ describe('Multi-file editor library dirty diff controller', () => { ); controller.decorate({ - data: { changes: computeDiff('123', '1234'), path: 'path' }, + data: { changes: computeDiff('123', '1234'), path: model.path }, }); expect(spy).toHaveBeenCalledWith( diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js index 2ccd87de1a7..ec56ebc0341 100644 --- a/spec/javascripts/ide/lib/editor_spec.js +++ b/spec/javascripts/ide/lib/editor_spec.js @@ -143,6 +143,31 @@ describe('Multi-file editor library', () => { }); }); + describe('attachMergeRequestModel', () => { + let model; + + beforeEach(() => { + instance.createDiffInstance(document.createElement('div')); + + const f = file(); + f.mrChanges = { diff: 'ABC' }; + f.baseRaw = 'testing'; + + model = instance.createModel(f); + }); + + it('sets original & modified', () => { + spyOn(instance.instance, 'setModel'); + + instance.attachMergeRequestModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith({ + original: model.getBaseModel(), + modified: model.getModel(), + }); + }); + }); + describe('clearEditor', () => { it('resets the editor model', () => { instance.createInstance(document.createElement('div')); diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js index 5b7c8365641..479ed7ce49e 100644 --- a/spec/javascripts/ide/stores/actions/file_spec.js +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -5,7 +5,7 @@ import router from '~/ide/ide_router'; import eventHub from '~/ide/eventhub'; import { file, resetStore } from '../../helpers'; -describe('Multi-file store file actions', () => { +describe('IDE store file actions', () => { beforeEach(() => { spyOn(router, 'push'); }); @@ -29,7 +29,7 @@ describe('Multi-file store file actions', () => { it('closes open files', done => { store - .dispatch('closeFile', localFile.path) + .dispatch('closeFile', localFile) .then(() => { expect(localFile.opened).toBeFalsy(); expect(localFile.active).toBeFalsy(); @@ -44,7 +44,7 @@ describe('Multi-file store file actions', () => { store.state.changedFiles.push(localFile); store - .dispatch('closeFile', localFile.path) + .dispatch('closeFile', localFile) .then(Vue.nextTick) .then(() => { expect(store.state.openFiles.length).toBe(0); @@ -65,7 +65,7 @@ describe('Multi-file store file actions', () => { store.state.entries[f.path] = f; store - .dispatch('closeFile', localFile.path) + .dispatch('closeFile', localFile) .then(Vue.nextTick) .then(() => { expect(router.push).toHaveBeenCalledWith(`/project${f.url}`); @@ -74,6 +74,22 @@ describe('Multi-file store file actions', () => { }) .catch(done.fail); }); + + it('removes file if it pending', done => { + store.state.openFiles.push({ + ...localFile, + pending: true, + }); + + store + .dispatch('closeFile', localFile) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); }); describe('setFileActive', () => { @@ -189,7 +205,7 @@ describe('Multi-file store file actions', () => { it('calls the service', done => { store - .dispatch('getFileData', localFile) + .dispatch('getFileData', { path: localFile.path }) .then(() => { expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL'); @@ -200,7 +216,7 @@ describe('Multi-file store file actions', () => { it('sets the file data', done => { store - .dispatch('getFileData', localFile) + .dispatch('getFileData', { path: localFile.path }) .then(() => { expect(localFile.blamePath).toBe('blame_path'); @@ -211,7 +227,7 @@ describe('Multi-file store file actions', () => { it('sets document title', done => { store - .dispatch('getFileData', localFile) + .dispatch('getFileData', { path: localFile.path }) .then(() => { expect(document.title).toBe('testing getFileData'); @@ -222,7 +238,7 @@ describe('Multi-file store file actions', () => { it('sets the file as active', done => { store - .dispatch('getFileData', localFile) + .dispatch('getFileData', { path: localFile.path }) .then(() => { expect(localFile.active).toBeTruthy(); @@ -231,9 +247,20 @@ describe('Multi-file store file actions', () => { .catch(done.fail); }); + it('sets the file not as active if we pass makeFileActive false', done => { + store + .dispatch('getFileData', { path: localFile.path, makeFileActive: false }) + .then(() => { + expect(localFile.active).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + it('adds the file to open files', done => { store - .dispatch('getFileData', localFile) + .dispatch('getFileData', { path: localFile.path }) .then(() => { expect(store.state.openFiles.length).toBe(1); expect(store.state.openFiles[0].name).toBe(localFile.name); @@ -256,7 +283,7 @@ describe('Multi-file store file actions', () => { it('calls getRawFileData service method', done => { store - .dispatch('getRawFileData', tmpFile) + .dispatch('getRawFileData', { path: tmpFile.path }) .then(() => { expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); @@ -267,7 +294,7 @@ describe('Multi-file store file actions', () => { it('updates file raw data', done => { store - .dispatch('getRawFileData', tmpFile) + .dispatch('getRawFileData', { path: tmpFile.path }) .then(() => { expect(tmpFile.raw).toBe('raw'); @@ -275,6 +302,22 @@ describe('Multi-file store file actions', () => { }) .catch(done.fail); }); + + it('calls also getBaseRawFileData service method', done => { + spyOn(service, 'getBaseRawFileData').and.returnValue(Promise.resolve('baseraw')); + + tmpFile.mrChange = { new_file: false }; + + store + .dispatch('getRawFileData', { path: tmpFile.path, baseSha: 'SHA' }) + .then(() => { + expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA'); + expect(tmpFile.baseRaw).toBe('baseraw'); + + done(); + }) + .catch(done.fail); + }); }); describe('changeFileContent', () => { @@ -418,4 +461,113 @@ describe('Multi-file store file actions', () => { .catch(done.fail); }); }); + + describe('openPendingTab', () => { + let f; + + beforeEach(() => { + f = { + ...file(), + projectId: '123', + }; + + store.state.entries[f.path] = f; + }); + + it('makes file pending in openFiles', done => { + store + .dispatch('openPendingTab', f) + .then(() => { + expect(store.state.openFiles[0].pending).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + + it('returns true when opened', done => { + store + .dispatch('openPendingTab', f) + .then(added => { + expect(added).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + + it('pushes router URL when added', done => { + store.state.currentBranchId = 'master'; + + store + .dispatch('openPendingTab', f) + .then(() => { + expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/'); + }) + .then(done) + .catch(done.fail); + }); + + it('calls scrollToTab', done => { + const scrollToTabSpy = jasmine.createSpy('scrollToTab'); + const oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line + store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line + + store + .dispatch('openPendingTab', f) + .then(() => { + expect(scrollToTabSpy).toHaveBeenCalled(); + store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line + }) + .then(done) + .catch(done.fail); + }); + + it('returns false when passed in file is active & viewer is diff', done => { + f.active = true; + store.state.openFiles.push(f); + store.state.viewer = 'diff'; + + store + .dispatch('openPendingTab', f) + .then(added => { + expect(added).toBe(false); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('removePendingTab', () => { + let f; + + beforeEach(() => { + spyOn(eventHub, '$emit'); + + f = { + ...file('pendingFile'), + pending: true, + }; + }); + + it('removes pending file from open files', done => { + store.state.openFiles.push(f); + + store + .dispatch('removePendingTab', f) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('emits event to dispose model', done => { + store + .dispatch('removePendingTab', f) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.${f.key}`); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/ide/stores/actions/merge_request_spec.js b/spec/javascripts/ide/stores/actions/merge_request_spec.js new file mode 100644 index 00000000000..b4ec4a0b173 --- /dev/null +++ b/spec/javascripts/ide/stores/actions/merge_request_spec.js @@ -0,0 +1,110 @@ +import store from '~/ide/stores'; +import service from '~/ide/services'; +import { resetStore } from '../../helpers'; + +describe('IDE store merge request actions', () => { + beforeEach(() => { + store.state.projects.abcproject = { + mergeRequests: {}, + }; + }); + + afterEach(() => { + resetStore(store); + }); + + describe('getMergeRequestData', () => { + beforeEach(() => { + spyOn(service, 'getProjectMergeRequestData').and.returnValue( + Promise.resolve({ data: { title: 'mergerequest' } }), + ); + }); + + it('calls getProjectMergeRequestData service method', done => { + store + .dispatch('getMergeRequestData', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(service.getProjectMergeRequestData).toHaveBeenCalledWith('abcproject', 1); + + done(); + }) + .catch(done.fail); + }); + + it('sets the Merge Request Object', done => { + store + .dispatch('getMergeRequestData', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(store.state.projects.abcproject.mergeRequests['1'].title).toBe('mergerequest'); + expect(store.state.currentMergeRequestId).toBe(1); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getMergeRequestChanges', () => { + beforeEach(() => { + spyOn(service, 'getProjectMergeRequestChanges').and.returnValue( + Promise.resolve({ data: { title: 'mergerequest' } }), + ); + + store.state.projects.abcproject.mergeRequests['1'] = { changes: [] }; + }); + + it('calls getProjectMergeRequestChanges service method', done => { + store + .dispatch('getMergeRequestChanges', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith('abcproject', 1); + + done(); + }) + .catch(done.fail); + }); + + it('sets the Merge Request Changes Object', done => { + store + .dispatch('getMergeRequestChanges', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(store.state.projects.abcproject.mergeRequests['1'].changes.title).toBe( + 'mergerequest', + ); + done(); + }) + .catch(done.fail); + }); + }); + + describe('getMergeRequestVersions', () => { + beforeEach(() => { + spyOn(service, 'getProjectMergeRequestVersions').and.returnValue( + Promise.resolve({ data: [{ id: 789 }] }), + ); + + store.state.projects.abcproject.mergeRequests['1'] = { versions: [] }; + }); + + it('calls getProjectMergeRequestVersions service method', done => { + store + .dispatch('getMergeRequestVersions', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith('abcproject', 1); + + done(); + }) + .catch(done.fail); + }); + + it('sets the Merge Request Versions Object', done => { + store + .dispatch('getMergeRequestVersions', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(store.state.projects.abcproject.mergeRequests['1'].versions.length).toBe(1); + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js index 381f038067b..e0ef57a3966 100644 --- a/spec/javascripts/ide/stores/actions/tree_spec.js +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -68,9 +68,7 @@ describe('Multi-file store tree actions', () => { expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); expect(projectTree.tree[1].type).toBe('blob'); expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); - expect(projectTree.tree[0].tree[0].tree[0].name).toBe( - 'fileinsubfolder.js', - ); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); done(); }) @@ -132,9 +130,7 @@ describe('Multi-file store tree actions', () => { store .dispatch('getLastCommitData', projectTree) .then(() => { - expect(service.getTreeLastCommit).toHaveBeenCalledWith( - 'lastcommitpath', - ); + expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath'); done(); }) @@ -160,9 +156,7 @@ describe('Multi-file store tree actions', () => { .dispatch('getLastCommitData', projectTree) .then(Vue.nextTick) .then(() => { - expect(projectTree.tree[0].lastCommit.message).not.toBe( - 'commit message', - ); + expect(projectTree.tree[0].lastCommit.message).not.toBe('commit message'); done(); }) diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js index a613f3a21cc..33733b97dff 100644 --- a/spec/javascripts/ide/stores/getters_spec.js +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -2,7 +2,7 @@ import * as getters from '~/ide/stores/getters'; import state from '~/ide/stores/state'; import { file } from '../helpers'; -describe('Multi-file store getters', () => { +describe('IDE store getters', () => { let localState; beforeEach(() => { @@ -52,4 +52,24 @@ describe('Multi-file store getters', () => { expect(modifiedFiles[0].name).toBe('added'); }); }); + + describe('currentMergeRequest', () => { + it('returns Current Merge Request', () => { + localState.currentProjectId = 'abcproject'; + localState.currentMergeRequestId = 1; + localState.projects.abcproject = { + mergeRequests: { + 1: { mergeId: 1 }, + }, + }; + + expect(getters.currentMergeRequest(localState).mergeId).toBe(1); + }); + + it('returns null if no active Merge Request was found', () => { + localState.currentProjectId = 'otherproject'; + + expect(getters.currentMergeRequest(localState)).toBeNull(); + }); + }); }); diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js index 131380248e8..88285ee409f 100644 --- a/spec/javascripts/ide/stores/mutations/file_spec.js +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -2,7 +2,7 @@ import mutations from '~/ide/stores/mutations/file'; import state from '~/ide/stores/state'; import { file } from '../../helpers'; -describe('Multi-file store file mutations', () => { +describe('IDE store file mutations', () => { let localState; let localFile; @@ -22,6 +22,21 @@ describe('Multi-file store file mutations', () => { expect(localFile.active).toBeTruthy(); }); + + it('sets pending tab as not active', () => { + localState.openFiles.push({ + ...localFile, + pending: true, + active: true, + }); + + mutations.SET_FILE_ACTIVE(localState, { + path: localFile.path, + active: true, + }); + + expect(localState.openFiles[0].active).toBe(false); + }); }); describe('TOGGLE_FILE_OPEN', () => { @@ -62,6 +77,8 @@ describe('Multi-file store file mutations', () => { expect(localFile.rawPath).toBe('raw'); expect(localFile.binary).toBeTruthy(); expect(localFile.renderError).toBe('render_error'); + expect(localFile.raw).toBeNull(); + expect(localFile.baseRaw).toBeNull(); }); }); @@ -76,6 +93,17 @@ describe('Multi-file store file mutations', () => { }); }); + describe('SET_FILE_BASE_RAW_DATA', () => { + it('sets raw data from base branch', () => { + mutations.SET_FILE_BASE_RAW_DATA(localState, { + file: localFile, + baseRaw: 'testing', + }); + + expect(localFile.baseRaw).toBe('testing'); + }); + }); + describe('UPDATE_FILE_CONTENT', () => { beforeEach(() => { localFile.raw = 'test'; @@ -112,6 +140,17 @@ describe('Multi-file store file mutations', () => { }); }); + describe('SET_FILE_MERGE_REQUEST_CHANGE', () => { + it('sets file mr change', () => { + mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, { + file: localFile, + mrChange: { diff: 'ABC' }, + }); + + expect(localFile.mrChange.diff).toBe('ABC'); + }); + }); + describe('DISCARD_FILE_CHANGES', () => { beforeEach(() => { localFile.content = 'test'; @@ -154,4 +193,69 @@ describe('Multi-file store file mutations', () => { expect(localFile.changed).toBeTruthy(); }); }); + + describe('ADD_PENDING_TAB', () => { + beforeEach(() => { + const f = { + ...file('openFile'), + path: 'openFile', + active: true, + opened: true, + }; + + localState.entries[f.path] = f; + localState.openFiles.push(f); + }); + + it('adds file into openFiles as pending', () => { + mutations.ADD_PENDING_TAB(localState, { file: localFile }); + + expect(localState.openFiles.length).toBe(2); + expect(localState.openFiles[1].pending).toBe(true); + expect(localState.openFiles[1].key).toBe(`pending-${localFile.key}`); + }); + + it('updates open file to pending', () => { + mutations.ADD_PENDING_TAB(localState, { file: localState.openFiles[0] }); + + expect(localState.openFiles.length).toBe(1); + }); + + it('updates pending open file to active', () => { + localState.openFiles.push({ + ...localFile, + pending: true, + }); + + mutations.ADD_PENDING_TAB(localState, { file: localFile }); + + expect(localState.openFiles[1].pending).toBe(true); + expect(localState.openFiles[1].active).toBe(true); + }); + + it('sets all openFiles to not active', () => { + mutations.ADD_PENDING_TAB(localState, { file: localFile }); + + expect(localState.openFiles.length).toBe(2); + + localState.openFiles.forEach(f => { + if (f.pending) { + expect(f.active).toBe(true); + } else { + expect(f.active).toBe(false); + } + }); + }); + }); + + describe('REMOVE_PENDING_TAB', () => { + it('removes pending tab from openFiles', () => { + localFile.key = 'testing'; + localState.openFiles.push(localFile); + + mutations.REMOVE_PENDING_TAB(localState, localFile); + + expect(localState.openFiles.length).toBe(0); + }); + }); }); diff --git a/spec/javascripts/ide/stores/mutations/merge_request_spec.js b/spec/javascripts/ide/stores/mutations/merge_request_spec.js new file mode 100644 index 00000000000..f724bf464f5 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/merge_request_spec.js @@ -0,0 +1,65 @@ +import mutations from '~/ide/stores/mutations/merge_request'; +import state from '~/ide/stores/state'; + +describe('IDE store merge request mutations', () => { + let localState; + + beforeEach(() => { + localState = state(); + localState.projects = { abcproject: { mergeRequests: {} } }; + + mutations.SET_MERGE_REQUEST(localState, { + projectPath: 'abcproject', + mergeRequestId: 1, + mergeRequest: { + title: 'mr', + }, + }); + }); + + describe('SET_CURRENT_MERGE_REQUEST', () => { + it('sets current merge request', () => { + mutations.SET_CURRENT_MERGE_REQUEST(localState, 2); + + expect(localState.currentMergeRequestId).toBe(2); + }); + }); + + describe('SET_MERGE_REQUEST', () => { + it('setsmerge request data', () => { + const newMr = localState.projects.abcproject.mergeRequests[1]; + + expect(newMr.title).toBe('mr'); + expect(newMr.active).toBeTruthy(); + }); + }); + + describe('SET_MERGE_REQUEST_CHANGES', () => { + it('sets merge request changes', () => { + mutations.SET_MERGE_REQUEST_CHANGES(localState, { + projectPath: 'abcproject', + mergeRequestId: 1, + changes: { + diff: 'abc', + }, + }); + + const newMr = localState.projects.abcproject.mergeRequests[1]; + expect(newMr.changes.diff).toBe('abc'); + }); + }); + + describe('SET_MERGE_REQUEST_VERSIONS', () => { + it('sets merge request versions', () => { + mutations.SET_MERGE_REQUEST_VERSIONS(localState, { + projectPath: 'abcproject', + mergeRequestId: 1, + versions: [{ id: 123 }], + }); + + const newMr = localState.projects.abcproject.mergeRequests[1]; + expect(newMr.versions.length).toBe(1); + expect(newMr.versions[0].id).toBe(123); + }); + }); +}); diff --git a/spec/javascripts/jobs/mock_data.js b/spec/javascripts/jobs/mock_data.js index 43589d54be4..25ca8eb6c0b 100644 --- a/spec/javascripts/jobs/mock_data.js +++ b/spec/javascripts/jobs/mock_data.js @@ -115,6 +115,10 @@ export default { commit_path: '/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6', }, }, + metadata: { + timeout_human_readable: '1m 40s', + timeout_source: 'runner', + }, merge_request: { iid: 2, path: '/root/ci-mock/merge_requests/2', diff --git a/spec/javascripts/jobs/sidebar_detail_row_spec.js b/spec/javascripts/jobs/sidebar_detail_row_spec.js index 3ac65709c4a..e6bfb0c4adc 100644 --- a/spec/javascripts/jobs/sidebar_detail_row_spec.js +++ b/spec/javascripts/jobs/sidebar_detail_row_spec.js @@ -37,4 +37,25 @@ describe('Sidebar detail row', () => { vm.$el.textContent.replace(/\s+/g, ' ').trim(), ).toEqual('this is the title: this is the value'); }); + + describe('when helpUrl not provided', () => { + it('should not render help', () => { + expect(vm.$el.querySelector('.help-button')).toBeNull(); + }); + }); + + describe('when helpUrl provided', () => { + beforeEach(() => { + vm = new SidebarDetailRow({ + propsData: { + helpUrl: 'help url', + value: 'foo', + }, + }).$mount(); + }); + + it('should render help', () => { + expect(vm.$el.querySelector('.help-button a').getAttribute('href')).toEqual('help url'); + }); + }); }); diff --git a/spec/javascripts/jobs/sidebar_details_block_spec.js b/spec/javascripts/jobs/sidebar_details_block_spec.js index 95532ef5382..602dae514b1 100644 --- a/spec/javascripts/jobs/sidebar_details_block_spec.js +++ b/spec/javascripts/jobs/sidebar_details_block_spec.js @@ -96,6 +96,12 @@ describe('Sidebar details block', () => { ).toEqual('Runner: #1'); }); + it('should render timeout information', () => { + expect( + trimWhitespace(vm.$el.querySelector('.js-job-timeout')), + ).toEqual('Timeout: 1m 40s (from runner)'); + }); + it('should render coverage', () => { expect( trimWhitespace(vm.$el.querySelector('.js-job-coverage')), diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js index 19504e4f7c8..cda550760fe 100644 --- a/spec/javascripts/notes/components/noteable_discussion_spec.js +++ b/spec/javascripts/notes/components/noteable_discussion_spec.js @@ -25,26 +25,34 @@ describe('issue_discussion component', () => { }); it('should render user avatar', () => { - expect(vm.$el.querySelector('.user-avatar-link')).toBeDefined(); + expect(vm.$el.querySelector('.user-avatar-link')).not.toBeNull(); }); it('should render discussion header', () => { - expect(vm.$el.querySelector('.discussion-header')).toBeDefined(); + expect(vm.$el.querySelector('.discussion-header')).not.toBeNull(); expect(vm.$el.querySelector('.notes').children.length).toEqual(discussionMock.notes.length); }); describe('actions', () => { it('should render reply button', () => { - expect(vm.$el.querySelector('.js-vue-discussion-reply').textContent.trim()).toEqual('Reply...'); + expect(vm.$el.querySelector('.js-vue-discussion-reply').textContent.trim()).toEqual( + 'Reply...', + ); }); - it('should toggle reply form', (done) => { + it('should toggle reply form', done => { vm.$el.querySelector('.js-vue-discussion-reply').click(); Vue.nextTick(() => { - expect(vm.$refs.noteForm).toBeDefined(); + expect(vm.$refs.noteForm).not.toBeNull(); expect(vm.isReplying).toEqual(true); done(); }); }); + + it('does not render jump to discussion button', () => { + expect( + vm.$el.querySelector('*[data-original-title="Jump to next unresolved discussion"]'), + ).toBeNull(); + }); }); }); diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index 5be13ed0dfe..2d88cee61f1 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -1,4 +1,3 @@ -/* eslint-disable */ export const notesDataMock = { discussionsPath: '/gitlab-org/gitlab-ce/issues/26/discussions.json', lastFetchedAt: 1501862675, @@ -43,7 +42,8 @@ export const noteableDataMock = { milestone: null, milestone_id: null, moved_to_id: null, - preview_note_path: '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue', + preview_note_path: + '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue', project_id: 2, state: 'opened', time_estimate: 0, @@ -60,465 +60,504 @@ export const individualNote = { expanded: true, id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', individual_note: true, - notes: [{ - id: 1390, - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: 'test', - path: '/root', + notes: [ + { + id: 1390, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: 'test', + path: '/root', + }, + created_at: '2017-08-01T17: 09: 33.762Z', + updated_at: '2017-08-01T17: 09: 33.762Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'sdfdsaf', + note_html: "<p dir='auto'>sdfdsaf</p>", + current_user: { can_edit: true }, + discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + emoji_awardable: true, + award_emoji: [ + { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } }, + { name: 'art', user: { id: 1, name: 'Root', username: 'root' } }, + ], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1390', }, - created_at: '2017-08-01T17: 09: 33.762Z', - updated_at: '2017-08-01T17: 09: 33.762Z', - system: false, - noteable_id: 98, - noteable_type: 'Issue', - type: null, - human_access: 'Owner', - note: 'sdfdsaf', - note_html: '<p dir=\'auto\'>sdfdsaf</p>', - current_user: { can_edit: true }, - discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', - emoji_awardable: true, - award_emoji: [ - { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } }, - { name: 'art', user: { id: 1, name: 'Root', username: 'root' } }, - ], - toggle_award_path: '/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji', - report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1', - path: '/gitlab-org/gitlab-ce/notes/1390', - }], + ], reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', }; export const note = { - "id": 546, - "attachment": { - "url": null, - "filename": null, - "image": false + id: 546, + attachment: { + url: null, + filename: null, + image: false, }, - "author": { - "id": 1, - "name": "Administrator", - "username": "root", - "state": "active", - "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "path": "/root" + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', }, - "created_at": "2017-08-10T15:24:03.087Z", - "updated_at": "2017-08-10T15:24:03.087Z", - "system": false, - "noteable_id": 67, - "noteable_type": "Issue", - "noteable_iid": 7, - "type": null, - "human_access": "Owner", - "note": "Vel id placeat reprehenderit sit numquam.", - "note_html": "<p dir=\"auto\">Vel id placeat reprehenderit sit numquam.</p>", - "current_user": { - "can_edit": true + created_at: '2017-08-10T15:24:03.087Z', + updated_at: '2017-08-10T15:24:03.087Z', + system: false, + noteable_id: 67, + noteable_type: 'Issue', + noteable_iid: 7, + type: null, + human_access: 'Owner', + note: 'Vel id placeat reprehenderit sit numquam.', + note_html: '<p dir="auto">Vel id placeat reprehenderit sit numquam.</p>', + current_user: { + can_edit: true, }, - "discussion_id": "d3842a451b7f3d9a5dfce329515127b2d29a4cd0", - "emoji_awardable": true, - "award_emoji": [{ - "name": "baseball", - "user": { - "id": 1, - "name": "Administrator", - "username": "root" - } - }, { - "name": "bath_tone3", - "user": { - "id": 1, - "name": "Administrator", - "username": "root" - } - }], - "toggle_award_path": "/gitlab-org/gitlab-ce/notes/546/toggle_award_emoji", - "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1", - "path": "/gitlab-org/gitlab-ce/notes/546" - } + discussion_id: 'd3842a451b7f3d9a5dfce329515127b2d29a4cd0', + emoji_awardable: true, + award_emoji: [ + { + name: 'baseball', + user: { + id: 1, + name: 'Administrator', + username: 'root', + }, + }, + { + name: 'bath_tone3', + user: { + id: 1, + name: 'Administrator', + username: 'root', + }, + }, + ], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/546/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1', + path: '/gitlab-org/gitlab-ce/notes/546', +}; export const discussionMock = { id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', reply_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', expanded: true, - notes: [{ - id: 1395, - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - created_at: '2017-08-02T10:51:58.559Z', - updated_at: '2017-08-02T10:51:58.559Z', - system: false, - noteable_id: 98, - noteable_type: 'Issue', - type: 'DiscussionNote', - human_access: 'Owner', - note: 'THIS IS A DICUSSSION!', - note_html: '<p dir=\'auto\'>THIS IS A DICUSSSION!</p>', - current_user: { - can_edit: true, - }, - discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', - emoji_awardable: true, - award_emoji: [], - toggle_award_path: '/gitlab-org/gitlab-ce/notes/1395/toggle_award_emoji', - report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1', - path: '/gitlab-org/gitlab-ce/notes/1395', - }, { - id: 1396, - attachment: { - url: null, - filename: null, - image: false, - }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - created_at: '2017-08-02T10:56:50.980Z', - updated_at: '2017-08-03T14:19:35.691Z', - system: false, - noteable_id: 98, - noteable_type: 'Issue', - type: 'DiscussionNote', - human_access: 'Owner', - note: 'sadfasdsdgdsf', - note_html: '<p dir=\'auto\'>sadfasdsdgdsf</p>', - last_edited_at: '2017-08-03T14:19:35.691Z', - last_edited_by: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', - }, - current_user: { - can_edit: true, + notes: [ + { + id: 1395, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:51:58.559Z', + updated_at: '2017-08-02T10:51:58.559Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'THIS IS A DICUSSSION!', + note_html: "<p dir='auto'>THIS IS A DICUSSSION!</p>", + current_user: { + can_edit: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1395/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1395', }, - discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', - emoji_awardable: true, - award_emoji: [], - toggle_award_path: '/gitlab-org/gitlab-ce/notes/1396/toggle_award_emoji', - report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1', - path: '/gitlab-org/gitlab-ce/notes/1396', - }, { - id: 1437, - attachment: { - url: null, - filename: null, - image: false, + { + id: 1396, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:56:50.980Z', + updated_at: '2017-08-03T14:19:35.691Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'sadfasdsdgdsf', + note_html: "<p dir='auto'>sadfasdsdgdsf</p>", + last_edited_at: '2017-08-03T14:19:35.691Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + current_user: { + can_edit: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1396/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1396', }, - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatar_url: null, - path: '/root', + { + id: 1437, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-03T18:11:18.780Z', + updated_at: '2017-08-04T09:52:31.062Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'adsfasf Should disappear', + note_html: "<p dir='auto'>adsfasf Should disappear</p>", + last_edited_at: '2017-08-04T09:52:31.062Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + current_user: { + can_edit: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1437/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1437', }, - created_at: '2017-08-03T18:11:18.780Z', - updated_at: '2017-08-04T09:52:31.062Z', - system: false, - noteable_id: 98, - noteable_type: 'Issue', - type: 'DiscussionNote', - human_access: 'Owner', - note: 'adsfasf Should disappear', - note_html: '<p dir=\'auto\'>adsfasf Should disappear</p>', - last_edited_at: '2017-08-04T09:52:31.062Z', - last_edited_by: { + ], + individual_note: false, +}; + +export const loggedOutnoteableData = { + id: 98, + iid: 26, + author_id: 1, + description: '', + lock_version: 1, + milestone_id: null, + state: 'opened', + title: 'asdsa', + updated_by_id: 1, + created_at: '2017-02-07T10:11:18.395Z', + updated_at: '2017-08-08T10:22:51.564Z', + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + milestone: null, + labels: [], + branch_name: null, + confidential: false, + assignees: [ + { id: 1, name: 'Root', username: 'root', state: 'active', avatar_url: null, - path: '/root', + web_url: 'http://localhost:3000/root', }, - current_user: { - can_edit: true, - }, - discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', - emoji_awardable: true, - award_emoji: [], - toggle_award_path: '/gitlab-org/gitlab-ce/notes/1437/toggle_award_emoji', - report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1', - path: '/gitlab-org/gitlab-ce/notes/1437', - }], - individual_note: false, -}; - -export const loggedOutnoteableData = { - "id": 98, - "iid": 26, - "author_id": 1, - "description": "", - "lock_version": 1, - "milestone_id": null, - "state": "opened", - "title": "asdsa", - "updated_by_id": 1, - "created_at": "2017-02-07T10:11:18.395Z", - "updated_at": "2017-08-08T10:22:51.564Z", - "time_estimate": 0, - "total_time_spent": 0, - "human_time_estimate": null, - "human_total_time_spent": null, - "milestone": null, - "labels": [], - "branch_name": null, - "confidential": false, - "assignees": [{ - "id": 1, - "name": "Root", - "username": "root", - "state": "active", - "avatar_url": null, - "web_url": "http://localhost:3000/root" - }], - "due_date": null, - "moved_to_id": null, - "project_id": 2, - "web_url": "/gitlab-org/gitlab-ce/issues/26", - "current_user": { - "can_create_note": false, - "can_update": false + ], + due_date: null, + moved_to_id: null, + project_id: 2, + web_url: '/gitlab-org/gitlab-ce/issues/26', + current_user: { + can_create_note: false, + can_update: false, }, - "create_note_path": "/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue", - "preview_note_path": "/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue" -} + create_note_path: '/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue', + preview_note_path: + '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue', +}; export const INDIVIDUAL_NOTE_RESPONSE_MAP = { - 'GET': { - '/gitlab-org/gitlab-ce/issues/26/discussions.json': [{ - "id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd", - "reply_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd", - "expanded": true, - "notes": [{ - "id": 1390, - "attachment": { - "url": null, - "filename": null, - "image": false - }, - "author": { - "id": 1, - "name": "Root", - "username": "root", - "state": "active", - "avatar_url": null, - "path": "/root" - }, - "created_at": "2017-08-01T17:09:33.762Z", - "updated_at": "2017-08-01T17:09:33.762Z", - "system": false, - "noteable_id": 98, - "noteable_type": "Issue", - "type": null, - "human_access": "Owner", - "note": "sdfdsaf", - "note_html": "\u003cp dir=\"auto\"\u003esdfdsaf\u003c/p\u003e", - "current_user": { - "can_edit": true - }, - "discussion_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd", - "emoji_awardable": true, - "award_emoji": [{ - "name": "baseball", - "user": { - "id": 1, - "name": "Root", - "username": "root" - } - }, { - "name": "art", - "user": { - "id": 1, - "name": "Root", - "username": "root" - } - }], - "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji", - "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1", - "path": "/gitlab-org/gitlab-ce/notes/1390" - }], - "individual_note": true - }, { - "id": "70d5c92a4039a36c70100c6691c18c27e4b0a790", - "reply_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790", - "expanded": true, - "notes": [{ - "id": 1391, - "attachment": { - "url": null, - "filename": null, - "image": false - }, - "author": { - "id": 1, - "name": "Root", - "username": "root", - "state": "active", - "avatar_url": null, - "path": "/root" - }, - "created_at": "2017-08-02T10:51:38.685Z", - "updated_at": "2017-08-02T10:51:38.685Z", - "system": false, - "noteable_id": 98, - "noteable_type": "Issue", - "type": null, - "human_access": "Owner", - "note": "New note!", - "note_html": "\u003cp dir=\"auto\"\u003eNew note!\u003c/p\u003e", - "current_user": { - "can_edit": true - }, - "discussion_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790", - "emoji_awardable": true, - "award_emoji": [], - "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1391/toggle_award_emoji", - "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1", - "path": "/gitlab-org/gitlab-ce/notes/1391" - }], - "individual_note": true - }], + GET: { + '/gitlab-org/gitlab-ce/issues/26/discussions.json': [ + { + id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + expanded: true, + notes: [ + { + id: 1390, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-01T17:09:33.762Z', + updated_at: '2017-08-01T17:09:33.762Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'sdfdsaf', + note_html: '\u003cp dir="auto"\u003esdfdsaf\u003c/p\u003e', + current_user: { + can_edit: true, + }, + discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + emoji_awardable: true, + award_emoji: [ + { + name: 'baseball', + user: { + id: 1, + name: 'Root', + username: 'root', + }, + }, + { + name: 'art', + user: { + id: 1, + name: 'Root', + username: 'root', + }, + }, + ], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1390', + }, + ], + individual_note: true, + }, + { + id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', + reply_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', + expanded: true, + notes: [ + { + id: 1391, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:51:38.685Z', + updated_at: '2017-08-02T10:51:38.685Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'New note!', + note_html: '\u003cp dir="auto"\u003eNew note!\u003c/p\u003e', + current_user: { + can_edit: true, + }, + discussion_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1391/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1391', + }, + ], + individual_note: true, + }, + ], '/gitlab-org/gitlab-ce/noteable/issue/98/notes': { last_fetched_at: 1512900838, notes: [], }, }, - 'PUT': { + PUT: { '/gitlab-org/gitlab-ce/notes/1471': { - "commands_changes": null, - "valid": true, - "id": 1471, - "attachment": null, - "author": { - "id": 1, - "name": "Root", - "username": "root", - "state": "active", - "avatar_url": null, - "path": "/root" + commands_changes: null, + valid: true, + id: 1471, + attachment: null, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', }, - "created_at": "2017-08-08T16:53:00.666Z", - "updated_at": "2017-12-10T11:03:21.876Z", - "system": false, - "noteable_id": 124, - "noteable_type": "Issue", - "noteable_iid": 29, - "type": "DiscussionNote", - "human_access": "Owner", - "note": "Adding a comment", - "note_html": "\u003cp dir=\"auto\"\u003eAdding a comment\u003c/p\u003e", - "last_edited_at": "2017-12-10T11:03:21.876Z", - "last_edited_by": { - "id": 1, - "name": 'Root', - "username": 'root', - "state": 'active', - "avatar_url": null, - "path": '/root', + created_at: '2017-08-08T16:53:00.666Z', + updated_at: '2017-12-10T11:03:21.876Z', + system: false, + noteable_id: 124, + noteable_type: 'Issue', + noteable_iid: 29, + type: 'DiscussionNote', + human_access: 'Owner', + note: 'Adding a comment', + note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e', + last_edited_at: '2017-12-10T11:03:21.876Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', }, - "current_user": { - "can_edit": true + current_user: { + can_edit: true, }, - "discussion_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052", - "emoji_awardable": true, - "award_emoji": [], - "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji", - "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1", - "path": "/gitlab-org/gitlab-ce/notes/1471" + discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1471', }, - } + }, }; export const DISCUSSION_NOTE_RESPONSE_MAP = { ...INDIVIDUAL_NOTE_RESPONSE_MAP, - 'GET': { + GET: { ...INDIVIDUAL_NOTE_RESPONSE_MAP.GET, - '/gitlab-org/gitlab-ce/issues/26/discussions.json': [{ - "id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052", - "reply_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052", - "expanded": true, - "notes": [{ - "id": 1471, - "attachment": { - "url": null, - "filename": null, - "image": false - }, - "author": { - "id": 1, - "name": "Root", - "username": "root", - "state": "active", - "avatar_url": null, - "path": "/root" - }, - "created_at": "2017-08-08T16:53:00.666Z", - "updated_at": "2017-08-08T16:53:00.666Z", - "system": false, - "noteable_id": 124, - "noteable_type": "Issue", - "noteable_iid": 29, - "type": "DiscussionNote", - "human_access": "Owner", - "note": "Adding a comment", - "note_html": "\u003cp dir=\"auto\"\u003eAdding a comment\u003c/p\u003e", - "current_user": { - "can_edit": true - }, - "discussion_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052", - "emoji_awardable": true, - "award_emoji": [], - "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji", - "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1", - "path": "/gitlab-org/gitlab-ce/notes/1471" - }], - "individual_note": false - }], + '/gitlab-org/gitlab-ce/issues/26/discussions.json': [ + { + id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + reply_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + expanded: true, + notes: [ + { + id: 1471, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-08T16:53:00.666Z', + updated_at: '2017-08-08T16:53:00.666Z', + system: false, + noteable_id: 124, + noteable_type: 'Issue', + noteable_iid: 29, + type: 'DiscussionNote', + human_access: 'Owner', + note: 'Adding a comment', + note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e', + current_user: { + can_edit: true, + }, + discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1471', + }, + ], + individual_note: false, + }, + ], }, }; export function individualNoteInterceptor(request, next) { const body = INDIVIDUAL_NOTE_RESPONSE_MAP[request.method.toUpperCase()][request.url]; - next(request.respondWith(JSON.stringify(body), { - status: 200, - })); + next( + request.respondWith(JSON.stringify(body), { + status: 200, + }), + ); } export function discussionNoteInterceptor(request, next) { const body = DISCUSSION_NOTE_RESPONSE_MAP[request.method.toUpperCase()][request.url]; - next(request.respondWith(JSON.stringify(body), { - status: 200, - })); + next( + request.respondWith(JSON.stringify(body), { + status: 200, + }), + ); } diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js index 91249b2c79e..520a25cc5c6 100644 --- a/spec/javascripts/notes/stores/actions_spec.js +++ b/spec/javascripts/notes/stores/actions_spec.js @@ -5,7 +5,13 @@ import * as actions from '~/notes/stores/actions'; import store from '~/notes/stores'; import testAction from '../../helpers/vuex_action_helper'; import { resetStore } from '../helpers'; -import { discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data'; +import { + discussionMock, + notesDataMock, + userDataMock, + noteableDataMock, + individualNote, +} from '../mock_data'; describe('Actions Notes Store', () => { afterEach(() => { @@ -13,66 +19,103 @@ describe('Actions Notes Store', () => { }); describe('setNotesData', () => { - it('should set received notes data', (done) => { - testAction(actions.setNotesData, null, { notesData: {} }, [ - { type: 'SET_NOTES_DATA', payload: notesDataMock }, - ], done); + it('should set received notes data', done => { + testAction( + actions.setNotesData, + notesDataMock, + { notesData: {} }, + [{ type: 'SET_NOTES_DATA', payload: notesDataMock }], + [], + done, + ); }); }); describe('setNoteableData', () => { - it('should set received issue data', (done) => { - testAction(actions.setNoteableData, null, { noteableData: {} }, [ - { type: 'SET_NOTEABLE_DATA', payload: noteableDataMock }, - ], done); + it('should set received issue data', done => { + testAction( + actions.setNoteableData, + noteableDataMock, + { noteableData: {} }, + [{ type: 'SET_NOTEABLE_DATA', payload: noteableDataMock }], + [], + done, + ); }); }); describe('setUserData', () => { - it('should set received user data', (done) => { - testAction(actions.setUserData, null, { userData: {} }, [ - { type: 'SET_USER_DATA', payload: userDataMock }, - ], done); + it('should set received user data', done => { + testAction( + actions.setUserData, + userDataMock, + { userData: {} }, + [{ type: 'SET_USER_DATA', payload: userDataMock }], + [], + done, + ); }); }); describe('setLastFetchedAt', () => { - it('should set received timestamp', (done) => { - testAction(actions.setLastFetchedAt, null, { lastFetchedAt: {} }, [ - { type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' }, - ], done); + it('should set received timestamp', done => { + testAction( + actions.setLastFetchedAt, + 'timestamp', + { lastFetchedAt: {} }, + [{ type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' }], + [], + done, + ); }); }); describe('setInitialNotes', () => { - it('should set initial notes', (done) => { - testAction(actions.setInitialNotes, null, { notes: [] }, [ - { type: 'SET_INITIAL_NOTES', payload: [individualNote] }, - ], done); + it('should set initial notes', done => { + testAction( + actions.setInitialNotes, + [individualNote], + { notes: [] }, + [{ type: 'SET_INITIAL_NOTES', payload: [individualNote] }], + [], + done, + ); }); }); describe('setTargetNoteHash', () => { - it('should set target note hash', (done) => { - testAction(actions.setTargetNoteHash, null, { notes: [] }, [ - { type: 'SET_TARGET_NOTE_HASH', payload: 'hash' }, - ], done); + it('should set target note hash', done => { + testAction( + actions.setTargetNoteHash, + 'hash', + { notes: [] }, + [{ type: 'SET_TARGET_NOTE_HASH', payload: 'hash' }], + [], + done, + ); }); }); describe('toggleDiscussion', () => { - it('should toggle discussion', (done) => { - testAction(actions.toggleDiscussion, null, { notes: [discussionMock] }, [ - { type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } }, - ], done); + it('should toggle discussion', done => { + testAction( + actions.toggleDiscussion, + { discussionId: discussionMock.id }, + { notes: [discussionMock] }, + [{ type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } }], + [], + done, + ); }); }); describe('async methods', () => { const interceptor = (request, next) => { - next(request.respondWith(JSON.stringify({}), { - status: 200, - })); + next( + request.respondWith(JSON.stringify({}), { + status: 200, + }), + ); }; beforeEach(() => { @@ -84,8 +127,9 @@ describe('Actions Notes Store', () => { }); describe('closeIssue', () => { - it('sets state as closed', (done) => { - store.dispatch('closeIssue', { notesData: { closeIssuePath: '' } }) + it('sets state as closed', done => { + store + .dispatch('closeIssue', { notesData: { closeIssuePath: '' } }) .then(() => { expect(store.state.noteableData.state).toEqual('closed'); expect(store.state.isToggleStateButtonLoading).toEqual(false); @@ -96,8 +140,9 @@ describe('Actions Notes Store', () => { }); describe('reopenIssue', () => { - it('sets state as reopened', (done) => { - store.dispatch('reopenIssue', { notesData: { reopenIssuePath: '' } }) + it('sets state as reopened', done => { + store + .dispatch('reopenIssue', { notesData: { reopenIssuePath: '' } }) .then(() => { expect(store.state.noteableData.state).toEqual('reopened'); expect(store.state.isToggleStateButtonLoading).toEqual(false); @@ -110,7 +155,7 @@ describe('Actions Notes Store', () => { describe('emitStateChangedEvent', () => { it('emits an event on the document', () => { - document.addEventListener('issuable_vue_app:change', (event) => { + document.addEventListener('issuable_vue_app:change', event => { expect(event.detail.data).toEqual({ id: '1', state: 'closed' }); expect(event.detail.isClosed).toEqual(false); }); @@ -120,40 +165,47 @@ describe('Actions Notes Store', () => { }); describe('toggleStateButtonLoading', () => { - it('should set loading as true', (done) => { - testAction(actions.toggleStateButtonLoading, true, {}, [ - { type: 'TOGGLE_STATE_BUTTON_LOADING', payload: true }, - ], done); + it('should set loading as true', done => { + testAction( + actions.toggleStateButtonLoading, + true, + {}, + [{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: true }], + [], + done, + ); }); - it('should set loading as false', (done) => { - testAction(actions.toggleStateButtonLoading, false, {}, [ - { type: 'TOGGLE_STATE_BUTTON_LOADING', payload: false }, - ], done); + it('should set loading as false', done => { + testAction( + actions.toggleStateButtonLoading, + false, + {}, + [{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: false }], + [], + done, + ); }); }); describe('toggleIssueLocalState', () => { - it('sets issue state as closed', (done) => { - testAction(actions.toggleIssueLocalState, 'closed', {}, [ - { type: 'CLOSE_ISSUE', payload: 'closed' }, - ], done); + it('sets issue state as closed', done => { + testAction(actions.toggleIssueLocalState, 'closed', {}, [{ type: 'CLOSE_ISSUE' }], [], done); }); - it('sets issue state as reopened', (done) => { - testAction(actions.toggleIssueLocalState, 'reopened', {}, [ - { type: 'REOPEN_ISSUE', payload: 'reopened' }, - ], done); + it('sets issue state as reopened', done => { + testAction(actions.toggleIssueLocalState, 'reopened', {}, [{ type: 'REOPEN_ISSUE' }], [], done); }); }); describe('poll', () => { - beforeEach((done) => { + beforeEach(done => { jasmine.clock().install(); spyOn(Vue.http, 'get').and.callThrough(); - store.dispatch('setNotesData', notesDataMock) + store + .dispatch('setNotesData', notesDataMock) .then(done) .catch(done.fail); }); @@ -162,23 +214,29 @@ describe('Actions Notes Store', () => { jasmine.clock().uninstall(); }); - it('calls service with last fetched state', (done) => { + it('calls service with last fetched state', done => { const interceptor = (request, next) => { - next(request.respondWith(JSON.stringify({ - notes: [], - last_fetched_at: '123456', - }), { - status: 200, - headers: { - 'poll-interval': '1000', - }, - })); + next( + request.respondWith( + JSON.stringify({ + notes: [], + last_fetched_at: '123456', + }), + { + status: 200, + headers: { + 'poll-interval': '1000', + }, + }, + ), + ); }; Vue.http.interceptors.push(interceptor); Vue.http.interceptors.push(headersInterceptor); - store.dispatch('poll') + store + .dispatch('poll') .then(() => new Promise(resolve => requestAnimationFrame(resolve))) .then(() => { expect(Vue.http.get).toHaveBeenCalledWith(jasmine.anything(), { @@ -192,9 +250,12 @@ describe('Actions Notes Store', () => { jasmine.clock().tick(1500); }) - .then(() => new Promise((resolve) => { - requestAnimationFrame(resolve); - })) + .then( + () => + new Promise(resolve => { + requestAnimationFrame(resolve); + }), + ) .then(() => { expect(Vue.http.get.calls.count()).toBe(2); expect(Vue.http.get.calls.mostRecent().args[1].headers).toEqual({ diff --git a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js index 080158a8ee0..a24f8204fe1 100644 --- a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js +++ b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js @@ -12,6 +12,7 @@ describe('Promote label modal', () => { labelColor: '#5cb85c', labelTextColor: '#ffffff', url: `${gl.TEST_HOST}/dummy/promote/labels`, + groupName: 'group', }; describe('Modal title and description', () => { @@ -24,7 +25,7 @@ describe('Promote label modal', () => { }); it('contains the proper description', () => { - expect(vm.text).toContain('Promoting this label will make it available for all projects inside the group'); + expect(vm.text).toContain(`Promoting ${labelMockData.labelTitle} will make it available for all projects inside ${labelMockData.groupName}`); }); it('contains a label span with the color', () => { diff --git a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js index 22956929e7b..8b220423637 100644 --- a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js +++ b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js @@ -10,6 +10,7 @@ describe('Promote milestone modal', () => { const milestoneMockData = { milestoneTitle: 'v1.0', url: `${gl.TEST_HOST}/dummy/promote/milestones`, + groupName: 'group', }; describe('Modal title and description', () => { @@ -22,7 +23,7 @@ describe('Promote milestone modal', () => { }); it('contains the proper description', () => { - expect(vm.text).toContain('Promoting this milestone will make it available for all projects inside the group.'); + expect(vm.text).toContain(`Promoting ${milestoneMockData.milestoneTitle} will make it available for all projects inside ${milestoneMockData.groupName}.`); }); it('contains the correct title', () => { diff --git a/spec/javascripts/pipelines/graph/mock_data.js b/spec/javascripts/pipelines/graph/mock_data.js index b9494f86d74..70eba98e939 100644 --- a/spec/javascripts/pipelines/graph/mock_data.js +++ b/spec/javascripts/pipelines/graph/mock_data.js @@ -1,232 +1,261 @@ -/* eslint-disable quote-props, quotes, comma-dangle */ export default { - "id": 123, - "user": { - "name": "Root", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": null, - "web_url": "http://localhost:3000/root" + id: 123, + user: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', }, - "active": false, - "coverage": null, - "path": "/root/ci-mock/pipelines/123", - "details": { - "status": { - "icon": "icon_status_success", - "text": "passed", - "label": "passed", - "group": "success", - "has_details": true, - "details_path": "/root/ci-mock/pipelines/123", - "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico" + active: false, + coverage: null, + path: '/root/ci-mock/pipelines/123', + details: { + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', }, - "duration": 9, - "finished_at": "2017-04-19T14:30:27.542Z", - "stages": [{ - "name": "test", - "title": "test: passed", - "groups": [{ - "name": "test", - "size": 1, - "status": { - "icon": "icon_status_success", - "text": "passed", - "label": "passed", - "group": "success", - "has_details": true, - "details_path": "/root/ci-mock/builds/4153", - "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/root/ci-mock/builds/4153/retry", - "method": "post" - } + duration: 9, + finished_at: '2017-04-19T14:30:27.542Z', + stages: [ + { + name: 'test', + title: 'test: passed', + groups: [ + { + name: 'test', + size: 1, + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4153', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4153/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4153, + name: 'test', + build_path: '/root/ci-mock/builds/4153', + retry_path: '/root/ci-mock/builds/4153/retry', + playable: false, + created_at: '2017-04-13T09:25:18.959Z', + updated_at: '2017-04-13T09:25:23.118Z', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4153', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4153/retry', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123#test', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', }, - "jobs": [{ - "id": 4153, - "name": "test", - "build_path": "/root/ci-mock/builds/4153", - "retry_path": "/root/ci-mock/builds/4153/retry", - "playable": false, - "created_at": "2017-04-13T09:25:18.959Z", - "updated_at": "2017-04-13T09:25:23.118Z", - "status": { - "icon": "icon_status_success", - "text": "passed", - "label": "passed", - "group": "success", - "has_details": true, - "details_path": "/root/ci-mock/builds/4153", - "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/root/ci-mock/builds/4153/retry", - "method": "post" - } - } - }] - }], - "status": { - "icon": "icon_status_success", - "text": "passed", - "label": "passed", - "group": "success", - "has_details": true, - "details_path": "/root/ci-mock/pipelines/123#test", - "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico" + path: '/root/ci-mock/pipelines/123#test', + dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test', }, - "path": "/root/ci-mock/pipelines/123#test", - "dropdown_path": "/root/ci-mock/pipelines/123/stage.json?stage=test" - }, { - "name": "deploy", - "title": "deploy: passed", - "groups": [{ - "name": "deploy to production", - "size": 1, - "status": { - "icon": "icon_status_success", - "text": "passed", - "label": "passed", - "group": "success", - "has_details": true, - "details_path": "/root/ci-mock/builds/4166", - "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/root/ci-mock/builds/4166/retry", - "method": "post" - } + { + name: 'deploy', + title: 'deploy: passed', + groups: [ + { + name: 'deploy to production', + size: 1, + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4166', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4166/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4166, + name: 'deploy to production', + build_path: '/root/ci-mock/builds/4166', + retry_path: '/root/ci-mock/builds/4166/retry', + playable: false, + created_at: '2017-04-19T14:29:46.463Z', + updated_at: '2017-04-19T14:30:27.498Z', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4166', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4166/retry', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'deploy to staging', + size: 1, + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4159', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4159/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4159, + name: 'deploy to staging', + build_path: '/root/ci-mock/builds/4159', + retry_path: '/root/ci-mock/builds/4159/retry', + playable: false, + created_at: '2017-04-18T16:32:08.420Z', + updated_at: '2017-04-18T16:32:12.631Z', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4159', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4159/retry', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123#deploy', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', }, - "jobs": [{ - "id": 4166, - "name": "deploy to production", - "build_path": "/root/ci-mock/builds/4166", - "retry_path": "/root/ci-mock/builds/4166/retry", - "playable": false, - "created_at": "2017-04-19T14:29:46.463Z", - "updated_at": "2017-04-19T14:30:27.498Z", - "status": { - "icon": "icon_status_success", - "text": "passed", - "label": "passed", - "group": "success", - "has_details": true, - "details_path": "/root/ci-mock/builds/4166", - "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/root/ci-mock/builds/4166/retry", - "method": "post" - } - } - }] - }, { - "name": "deploy to staging", - "size": 1, - "status": { - "icon": "icon_status_success", - "text": "passed", - "label": "passed", - "group": "success", - "has_details": true, - "details_path": "/root/ci-mock/builds/4159", - "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/root/ci-mock/builds/4159/retry", - "method": "post" - } - }, - "jobs": [{ - "id": 4159, - "name": "deploy to staging", - "build_path": "/root/ci-mock/builds/4159", - "retry_path": "/root/ci-mock/builds/4159/retry", - "playable": false, - "created_at": "2017-04-18T16:32:08.420Z", - "updated_at": "2017-04-18T16:32:12.631Z", - "status": { - "icon": "icon_status_success", - "text": "passed", - "label": "passed", - "group": "success", - "has_details": true, - "details_path": "/root/ci-mock/builds/4159", - "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/root/ci-mock/builds/4159/retry", - "method": "post" - } - } - }] - }], - "status": { - "icon": "icon_status_success", - "text": "passed", - "label": "passed", - "group": "success", - "has_details": true, - "details_path": "/root/ci-mock/pipelines/123#deploy", - "favicon": "/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico" + path: '/root/ci-mock/pipelines/123#deploy', + dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=deploy', + }, + ], + artifacts: [], + manual_actions: [ + { + name: 'deploy to production', + path: '/root/ci-mock/builds/4166/play', + playable: false, }, - "path": "/root/ci-mock/pipelines/123#deploy", - "dropdown_path": "/root/ci-mock/pipelines/123/stage.json?stage=deploy" - }], - "artifacts": [], - "manual_actions": [{ - "name": "deploy to production", - "path": "/root/ci-mock/builds/4166/play", - "playable": false - }] + ], }, - "flags": { - "latest": true, - "triggered": false, - "stuck": false, - "yaml_errors": false, - "retryable": false, - "cancelable": false + flags: { + latest: true, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: false, + cancelable: false, }, - "ref": { - "name": "master", - "path": "/root/ci-mock/tree/master", - "tag": false, - "branch": true + ref: { + name: 'master', + path: '/root/ci-mock/tree/master', + tag: false, + branch: true, }, - "commit": { - "id": "798e5f902592192afaba73f4668ae30e56eae492", - "short_id": "798e5f90", - "title": "Merge branch 'new-branch' into 'master'\r", - "created_at": "2017-04-13T10:25:17.000+01:00", - "parent_ids": ["54d483b1ed156fbbf618886ddf7ab023e24f8738", "c8e2d38a6c538822e81c57022a6e3a0cfedebbcc"], - "message": "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", - "author_name": "Root", - "author_email": "admin@example.com", - "authored_date": "2017-04-13T10:25:17.000+01:00", - "committer_name": "Root", - "committer_email": "admin@example.com", - "committed_date": "2017-04-13T10:25:17.000+01:00", - "author": { - "name": "Root", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": null, - "web_url": "http://localhost:3000/root" + commit: { + id: '798e5f902592192afaba73f4668ae30e56eae492', + short_id: '798e5f90', + title: "Merge branch 'new-branch' into 'master'\r", + created_at: '2017-04-13T10:25:17.000+01:00', + parent_ids: [ + '54d483b1ed156fbbf618886ddf7ab023e24f8738', + 'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc', + ], + message: + "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", + author_name: 'Root', + author_email: 'admin@example.com', + authored_date: '2017-04-13T10:25:17.000+01:00', + committer_name: 'Root', + committer_email: 'admin@example.com', + committed_date: '2017-04-13T10:25:17.000+01:00', + author: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', }, - "author_gravatar_url": null, - "commit_url": "http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492", - "commit_path": "/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492" + author_gravatar_url: null, + commit_url: + 'http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', + commit_path: '/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', }, - "created_at": "2017-04-13T09:25:18.881Z", - "updated_at": "2017-04-19T14:30:27.561Z" + created_at: '2017-04-13T09:25:18.881Z', + updated_at: '2017-04-19T14:30:27.561Z', }; diff --git a/spec/javascripts/registry/stores/actions_spec.js b/spec/javascripts/registry/stores/actions_spec.js index 3c9da4f107b..bc4c444655a 100644 --- a/spec/javascripts/registry/stores/actions_spec.js +++ b/spec/javascripts/registry/stores/actions_spec.js @@ -29,57 +29,96 @@ describe('Actions Registry Store', () => { describe('fetchRepos', () => { beforeEach(() => { interceptor = (request, next) => { - next(request.respondWith(JSON.stringify(reposServerResponse), { - status: 200, - })); + next( + request.respondWith(JSON.stringify(reposServerResponse), { + status: 200, + }), + ); }; Vue.http.interceptors.push(interceptor); }); - it('should set receveived repos', (done) => { - testAction(actions.fetchRepos, null, mockedState, [ - { type: types.TOGGLE_MAIN_LOADING }, - { type: types.SET_REPOS_LIST, payload: reposServerResponse }, - ], done); + it('should set receveived repos', done => { + testAction( + actions.fetchRepos, + null, + mockedState, + [ + { type: types.TOGGLE_MAIN_LOADING }, + { type: types.TOGGLE_MAIN_LOADING }, + { type: types.SET_REPOS_LIST, payload: reposServerResponse }, + ], + [], + done, + ); }); }); describe('fetchList', () => { beforeEach(() => { interceptor = (request, next) => { - next(request.respondWith(JSON.stringify(registryServerResponse), { - status: 200, - })); + next( + request.respondWith(JSON.stringify(registryServerResponse), { + status: 200, + }), + ); }; Vue.http.interceptors.push(interceptor); }); - it('should set received list', (done) => { + it('should set received list', done => { mockedState.repos = parsedReposServerResponse; - testAction(actions.fetchList, { repo: mockedState.repos[1] }, mockedState, [ - { type: types.TOGGLE_REGISTRY_LIST_LOADING }, - { type: types.SET_REGISTRY_LIST, payload: registryServerResponse }, - ], done); + const repo = mockedState.repos[1]; + + testAction( + actions.fetchList, + { repo }, + mockedState, + [ + { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo }, + { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo }, + { + type: types.SET_REGISTRY_LIST, + payload: { + repo, + resp: registryServerResponse, + headers: jasmine.anything(), + }, + }, + ], + [], + done, + ); }); }); }); describe('setMainEndpoint', () => { - it('should commit set main endpoint', (done) => { - testAction(actions.setMainEndpoint, 'endpoint', mockedState, [ - { type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' }, - ], done); + it('should commit set main endpoint', done => { + testAction( + actions.setMainEndpoint, + 'endpoint', + mockedState, + [{ type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' }], + [], + done, + ); }); }); describe('toggleLoading', () => { - it('should commit toggle main loading', (done) => { - testAction(actions.toggleLoading, null, mockedState, [ - { type: types.TOGGLE_MAIN_LOADING }, - ], done); + it('should commit toggle main loading', done => { + testAction( + actions.toggleLoading, + null, + mockedState, + [{ type: types.TOGGLE_MAIN_LOADING }], + [], + done, + ); }); }); }); diff --git a/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js b/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js index 88a33caf2e3..0c173062835 100644 --- a/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js +++ b/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js @@ -62,4 +62,22 @@ describe('Confidential Issue Sidebar Block', () => { done(); }); }); + + it('displays the edit form when opened from collapsed state', (done) => { + expect(vm1.edit).toBe(false); + + vm1.$el.querySelector('.sidebar-collapsed-icon').click(); + + expect(vm1.edit).toBe(true); + + setTimeout(() => { + expect( + vm1.$el + .innerHTML + .includes('You are going to turn off the confidentiality.'), + ).toBe(true); + + done(); + }); + }); }); diff --git a/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js b/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js index 696fca516bc..9abc3daf221 100644 --- a/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js +++ b/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js @@ -68,4 +68,22 @@ describe('LockIssueSidebar', () => { done(); }); }); + + it('displays the edit form when opened from collapsed state', (done) => { + expect(vm1.isLockDialogOpen).toBe(false); + + vm1.$el.querySelector('.sidebar-collapsed-icon').click(); + + expect(vm1.isLockDialogOpen).toBe(true); + + setTimeout(() => { + expect( + vm1.$el + .innerHTML + .includes('Unlock this issue?'), + ).toBe(true); + + done(); + }); + }); }); diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js index d9e84e35f69..8b6e8b24f00 100644 --- a/spec/javascripts/sidebar/mock_data.js +++ b/spec/javascripts/sidebar/mock_data.js @@ -1,7 +1,5 @@ -/* eslint-disable quote-props*/ - const RESPONSE_MAP = { - 'GET': { + GET: { '/gitlab-org/gitlab-shell/issues/5.json': { id: 45, iid: 5, @@ -27,7 +25,8 @@ const RESPONSE_MAP = { username: 'user0', id: 22, state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon', + avatar_url: + 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon', web_url: 'http: //localhost:3001/user0', }, { @@ -35,7 +34,8 @@ const RESPONSE_MAP = { username: 'tajuana', id: 18, state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon', + avatar_url: + 'https://www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon', web_url: 'http: //localhost:3001/tajuana', }, { @@ -43,7 +43,8 @@ const RESPONSE_MAP = { username: 'michaele.will', id: 16, state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon', + avatar_url: + 'https://www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon', web_url: 'http: //localhost:3001/michaele.will', }, ], @@ -72,7 +73,8 @@ const RESPONSE_MAP = { username: 'user0', id: 22, state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon', + avatar_url: + 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon', web_url: 'http://localhost:3001/user0', }, { @@ -80,7 +82,8 @@ const RESPONSE_MAP = { username: 'tajuana', id: 18, state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon', + avatar_url: + 'https://www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon', web_url: 'http://localhost:3001/tajuana', }, { @@ -88,7 +91,8 @@ const RESPONSE_MAP = { username: 'michaele.will', id: 16, state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon', + avatar_url: + 'https://www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon', web_url: 'http://localhost:3001/michaele.will', }, ], @@ -100,7 +104,8 @@ const RESPONSE_MAP = { username: 'user0', id: 22, state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon', + avatar_url: + 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon', web_url: 'http://localhost:3001/user0', }, { @@ -108,7 +113,8 @@ const RESPONSE_MAP = { username: 'tajuana', id: 18, state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon', + avatar_url: + 'https://www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon', web_url: 'http://localhost:3001/tajuana', }, { @@ -116,7 +122,8 @@ const RESPONSE_MAP = { username: 'michaele.will', id: 16, state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon', + avatar_url: + 'https://www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon', web_url: 'http://localhost:3001/michaele.will', }, ], @@ -126,20 +133,21 @@ const RESPONSE_MAP = { }, '/autocomplete/projects?project_id=15': [ { - 'id': 0, - 'name_with_namespace': 'No project', - }, { - 'id': 20, - 'name_with_namespace': 'foo / bar', + id: 0, + name_with_namespace: 'No project', + }, + { + id: 20, + name_with_namespace: 'foo / bar', }, ], }, - 'PUT': { + PUT: { '/gitlab-org/gitlab-shell/issues/5.json': { data: {}, }, }, - 'POST': { + POST: { '/gitlab-org/gitlab-shell/issues/5/move': { id: 123, iid: 5, @@ -182,7 +190,8 @@ const mockData = { id: 1, name: 'Administrator', username: 'root', - avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', }, rootPath: '/', fullPath: '/gitlab-org/gitlab-shell', @@ -201,12 +210,14 @@ const mockData = { }, }; -mockData.sidebarMockInterceptor = function (request, next) { +mockData.sidebarMockInterceptor = function(request, next) { const body = this.responseMap[request.method.toUpperCase()][request.url]; - next(request.respondWith(JSON.stringify(body), { - status: 200, - })); + next( + request.respondWith(JSON.stringify(body), { + status: 200, + }), + ); }.bind(mockData); export default mockData; diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js index 235c33fac0d..9b9c9656979 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js @@ -17,46 +17,58 @@ describe('MRWidgetHeader', () => { describe('computed', () => { describe('shouldShowCommitsBehindText', () => { it('return true when there are divergedCommitsCount', () => { - vm = mountComponent(Component, { mr: { - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'master', - } }); + vm = mountComponent(Component, { + mr: { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', + targetBranch: 'master', + statusPath: 'abc', + }, + }); expect(vm.shouldShowCommitsBehindText).toEqual(true); }); it('returns false where there are no divergedComits count', () => { - vm = mountComponent(Component, { mr: { - divergedCommitsCount: 0, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'master', - } }); + vm = mountComponent(Component, { + mr: { + divergedCommitsCount: 0, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', + targetBranch: 'master', + statusPath: 'abc', + }, + }); expect(vm.shouldShowCommitsBehindText).toEqual(false); }); }); describe('commitsText', () => { it('returns singular when there is one commit', () => { - vm = mountComponent(Component, { mr: { - divergedCommitsCount: 1, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'master', - } }); + vm = mountComponent(Component, { + mr: { + divergedCommitsCount: 1, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', + targetBranch: 'master', + statusPath: 'abc', + }, + }); expect(vm.commitsText).toEqual('1 commit behind'); }); it('returns plural when there is more than one commit', () => { - vm = mountComponent(Component, { mr: { - divergedCommitsCount: 2, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'master', - } }); + vm = mountComponent(Component, { + mr: { + divergedCommitsCount: 2, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', + targetBranch: 'master', + statusPath: 'abc', + }, + }); expect(vm.commitsText).toEqual('2 commits behind'); }); @@ -66,24 +78,27 @@ describe('MRWidgetHeader', () => { describe('template', () => { describe('common elements', () => { beforeEach(() => { - vm = mountComponent(Component, { mr: { - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', - isOpen: true, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - } }); + vm = mountComponent(Component, { + mr: { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + targetBranch: 'master', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + statusPath: 'abc', + }, + }); }); it('renders source branch link', () => { - expect( - vm.$el.querySelector('.js-source-branch').innerHTML, - ).toEqual('<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>'); + expect(vm.$el.querySelector('.js-source-branch').innerHTML).toEqual( + '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + ); }); it('renders clipboard button', () => { @@ -101,18 +116,21 @@ describe('MRWidgetHeader', () => { }); beforeEach(() => { - vm = mountComponent(Component, { mr: { - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', - isOpen: true, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - } }); + vm = mountComponent(Component, { + mr: { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + targetBranch: 'master', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + statusPath: 'abc', + }, + }); }); it('renders checkout branch button with modal trigger', () => { @@ -123,39 +141,49 @@ describe('MRWidgetHeader', () => { expect(button.getAttribute('data-toggle')).toEqual('modal'); }); + it('renders web ide button', () => { + const button = vm.$el.querySelector('.js-web-ide'); + + expect(button.textContent.trim()).toEqual('Web IDE'); + expect(button.getAttribute('href')).toEqual('undefined/-/ide/projectabc'); + }); + it('renders download dropdown with links', () => { - expect( - vm.$el.querySelector('.js-download-email-patches').textContent.trim(), - ).toEqual('Email patches'); + expect(vm.$el.querySelector('.js-download-email-patches').textContent.trim()).toEqual( + 'Email patches', + ); - expect( - vm.$el.querySelector('.js-download-email-patches').getAttribute('href'), - ).toEqual('/mr/email-patches'); + expect(vm.$el.querySelector('.js-download-email-patches').getAttribute('href')).toEqual( + '/mr/email-patches', + ); - expect( - vm.$el.querySelector('.js-download-plain-diff').textContent.trim(), - ).toEqual('Plain diff'); + expect(vm.$el.querySelector('.js-download-plain-diff').textContent.trim()).toEqual( + 'Plain diff', + ); - expect( - vm.$el.querySelector('.js-download-plain-diff').getAttribute('href'), - ).toEqual('/mr/plainDiffPath'); + expect(vm.$el.querySelector('.js-download-plain-diff').getAttribute('href')).toEqual( + '/mr/plainDiffPath', + ); }); }); describe('with a closed merge request', () => { beforeEach(() => { - vm = mountComponent(Component, { mr: { - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', - isOpen: false, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - } }); + vm = mountComponent(Component, { + mr: { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + targetBranch: 'master', + isOpen: false, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + statusPath: 'abc', + }, + }); }); it('does not render checkout branch button with modal trigger', () => { @@ -165,30 +193,29 @@ describe('MRWidgetHeader', () => { }); it('does not render download dropdown with links', () => { - expect( - vm.$el.querySelector('.js-download-email-patches'), - ).toEqual(null); + expect(vm.$el.querySelector('.js-download-email-patches')).toEqual(null); - expect( - vm.$el.querySelector('.js-download-plain-diff'), - ).toEqual(null); + expect(vm.$el.querySelector('.js-download-plain-diff')).toEqual(null); }); }); describe('without diverged commits', () => { beforeEach(() => { - vm = mountComponent(Component, { mr: { - divergedCommitsCount: 0, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', - isOpen: true, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - } }); + vm = mountComponent(Component, { + mr: { + divergedCommitsCount: 0, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + targetBranch: 'master', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + statusPath: 'abc', + }, + }); }); it('does not render diverged commits info', () => { @@ -198,22 +225,27 @@ describe('MRWidgetHeader', () => { describe('with diverged commits', () => { beforeEach(() => { - vm = mountComponent(Component, { mr: { - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', - isOpen: true, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - } }); + vm = mountComponent(Component, { + mr: { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + targetBranch: 'master', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + statusPath: 'abc', + }, + }); }); it('renders diverged commits info', () => { - expect(vm.$el.querySelector('.diverged-commits-count').textContent.trim()).toEqual('(12 commits behind)'); + expect(vm.$el.querySelector('.diverged-commits-count').textContent.trim()).toEqual( + '(12 commits behind)', + ); }); }); }); diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js index 3dd75307484..3fc7663b9c2 100644 --- a/spec/javascripts/vue_mr_widget/mock_data.js +++ b/spec/javascripts/vue_mr_widget/mock_data.js @@ -1,213 +1,218 @@ -/* eslint-disable */ - export default { - "id": 132, - "iid": 22, - "assignee_id": null, - "author_id": 1, - "description": "", - "lock_version": null, - "milestone_id": null, - "position": 0, - "state": "merged", - "title": "Update README.md", - "updated_by_id": null, - "created_at": "2017-04-07T12:27:26.718Z", - "updated_at": "2017-04-07T15:39:25.852Z", - "time_estimate": 0, - "total_time_spent": 0, - "human_time_estimate": null, - "human_total_time_spent": null, - "in_progress_merge_commit_sha": null, - "merge_commit_sha": "53027d060246c8f47e4a9310fb332aa52f221775", - "merge_error": null, - "merge_params": { - "force_remove_source_branch": null + id: 132, + iid: 22, + assignee_id: null, + author_id: 1, + description: '', + lock_version: null, + milestone_id: null, + position: 0, + state: 'merged', + title: 'Update README.md', + updated_by_id: null, + created_at: '2017-04-07T12:27:26.718Z', + updated_at: '2017-04-07T15:39:25.852Z', + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + in_progress_merge_commit_sha: null, + merge_commit_sha: '53027d060246c8f47e4a9310fb332aa52f221775', + merge_error: null, + merge_params: { + force_remove_source_branch: null, }, - "merge_status": "can_be_merged", - "merge_user_id": null, - "merge_when_pipeline_succeeds": false, - "source_branch": "daaaa", - "source_branch_link": "daaaa", - "source_project_id": 19, - "target_branch": "master", - "target_project_id": 19, - "metrics": { - "merged_by": { - "name": "Administrator", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "web_url": "http://localhost:3000/root" + merge_status: 'can_be_merged', + merge_user_id: null, + merge_when_pipeline_succeeds: false, + source_branch: 'daaaa', + source_branch_link: 'daaaa', + source_project_id: 19, + target_branch: 'master', + target_project_id: 19, + metrics: { + merged_by: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3000/root', }, - "merged_at": "2017-04-07T15:39:25.696Z", - "closed_by": null, - "closed_at": null + merged_at: '2017-04-07T15:39:25.696Z', + closed_by: null, + closed_at: null, }, - "author": { - "name": "Administrator", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "web_url": "http://localhost:3000/root" + author: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3000/root', }, - "merge_user": null, - "diff_head_sha": "104096c51715e12e7ae41f9333e9fa35b73f385d", - "diff_head_commit_short_id": "104096c5", - "merge_commit_message": "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", - "pipeline": { - "id": 172, - "user": { - "name": "Administrator", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "web_url": "http://localhost:3000/root" + merge_user: null, + diff_head_sha: '104096c51715e12e7ae41f9333e9fa35b73f385d', + diff_head_commit_short_id: '104096c5', + merge_commit_message: + "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", + pipeline: { + id: 172, + user: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3000/root', }, - "active": false, - "coverage": "92.16", - "path": "/root/acets-app/pipelines/172", - "details": { - "status": { - "icon": "icon_status_success", - "favicon": "favicon_status_success", - "text": "passed", - "label": "passed", - "group": "success", - "has_details": true, - "details_path": "/root/acets-app/pipelines/172" + active: false, + coverage: '92.16', + path: '/root/acets-app/pipelines/172', + details: { + status: { + icon: 'icon_status_success', + favicon: 'favicon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/acets-app/pipelines/172', }, - "duration": null, - "finished_at": "2017-04-07T14:00:14.256Z", - "stages": [ + duration: null, + finished_at: '2017-04-07T14:00:14.256Z', + stages: [ { - "name": "build", - "title": "build: failed", - "status": { - "icon": "icon_status_failed", - "favicon": "favicon_status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "has_details": true, - "details_path": "/root/acets-app/pipelines/172#build" + name: 'build', + title: 'build: failed', + status: { + icon: 'icon_status_failed', + favicon: 'favicon_status_failed', + text: 'failed', + label: 'failed', + group: 'failed', + has_details: true, + details_path: '/root/acets-app/pipelines/172#build', }, - "path": "/root/acets-app/pipelines/172#build", - "dropdown_path": "/root/acets-app/pipelines/172/stage.json?stage=build" + path: '/root/acets-app/pipelines/172#build', + dropdown_path: '/root/acets-app/pipelines/172/stage.json?stage=build', }, { - "name": "review", - "title": "review: skipped", - "status": { - "icon": "icon_status_skipped", - "favicon": "favicon_status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "has_details": true, - "details_path": "/root/acets-app/pipelines/172#review" + name: 'review', + title: 'review: skipped', + status: { + icon: 'icon_status_skipped', + favicon: 'favicon_status_skipped', + text: 'skipped', + label: 'skipped', + group: 'skipped', + has_details: true, + details_path: '/root/acets-app/pipelines/172#review', }, - "path": "/root/acets-app/pipelines/172#review", - "dropdown_path": "/root/acets-app/pipelines/172/stage.json?stage=review" - } - ], - "artifacts": [ - + path: '/root/acets-app/pipelines/172#review', + dropdown_path: '/root/acets-app/pipelines/172/stage.json?stage=review', + }, ], - "manual_actions": [ + artifacts: [], + manual_actions: [ { - "name": "stop_review", - "path": "/root/acets-app/builds/1427/play", - "playable": false - } - ] + name: 'stop_review', + path: '/root/acets-app/builds/1427/play', + playable: false, + }, + ], }, - "flags": { - "latest": false, - "triggered": false, - "stuck": false, - "yaml_errors": false, - "retryable": true, - "cancelable": false + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: false, }, - "ref": { - "name": "daaaa", - "path": "/root/acets-app/tree/daaaa", - "tag": false, - "branch": true + ref: { + name: 'daaaa', + path: '/root/acets-app/tree/daaaa', + tag: false, + branch: true, }, - "commit": { - "id": "104096c51715e12e7ae41f9333e9fa35b73f385d", - "short_id": "104096c5", - "title": "Update README.md", - "created_at": "2017-04-07T15:27:18.000+03:00", - "parent_ids": [ - "2396536178668d8930c29d904e53bd4d06228b32" - ], - "message": "Update README.md", - "author_name": "Administrator", - "author_email": "admin@example.com", - "authored_date": "2017-04-07T15:27:18.000+03:00", - "committer_name": "Administrator", - "committer_email": "admin@example.com", - "committed_date": "2017-04-07T15:27:18.000+03:00", - "author": { - "name": "Administrator", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "web_url": "http://localhost:3000/root" + commit: { + id: '104096c51715e12e7ae41f9333e9fa35b73f385d', + short_id: '104096c5', + title: 'Update README.md', + created_at: '2017-04-07T15:27:18.000+03:00', + parent_ids: ['2396536178668d8930c29d904e53bd4d06228b32'], + message: 'Update README.md', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2017-04-07T15:27:18.000+03:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2017-04-07T15:27:18.000+03:00', + author: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3000/root', }, - "author_gravatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "commit_url": "http://localhost:3000/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d", - "commit_path": "/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d" + author_gravatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commit_url: + 'http://localhost:3000/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d', + commit_path: '/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d', }, - "retry_path": "/root/acets-app/pipelines/172/retry", - "created_at": "2017-04-07T12:27:19.520Z", - "updated_at": "2017-04-07T15:28:44.800Z" + retry_path: '/root/acets-app/pipelines/172/retry', + created_at: '2017-04-07T12:27:19.520Z', + updated_at: '2017-04-07T15:28:44.800Z', }, - "work_in_progress": false, - "source_branch_exists": false, - "mergeable_discussions_state": true, - "conflicts_can_be_resolved_in_ui": false, - "branch_missing": true, - "commits_count": 1, - "has_conflicts": false, - "can_be_merged": true, - "has_ci": true, - "ci_status": "success", - "pipeline_status_path": "/root/acets-app/merge_requests/22/pipeline_status", - "issues_links": { - "closing": "", - "mentioned_but_not_closing": "" + work_in_progress: false, + source_branch_exists: false, + mergeable_discussions_state: true, + conflicts_can_be_resolved_in_ui: false, + branch_missing: true, + commits_count: 1, + has_conflicts: false, + can_be_merged: true, + has_ci: true, + ci_status: 'success', + pipeline_status_path: '/root/acets-app/merge_requests/22/pipeline_status', + issues_links: { + closing: '', + mentioned_but_not_closing: '', }, - "current_user": { - "can_resolve_conflicts": true, - "can_remove_source_branch": false, - "can_revert_on_current_merge_request": true, - "can_cherry_pick_on_current_merge_request": true + current_user: { + can_resolve_conflicts: true, + can_remove_source_branch: false, + can_revert_on_current_merge_request: true, + can_cherry_pick_on_current_merge_request: true, }, - "target_branch_path": "/root/acets-app/branches/master", - "source_branch_path": "/root/acets-app/branches/daaaa", - "conflict_resolution_ui_path": "/root/acets-app/merge_requests/22/conflicts", - "remove_wip_path": "/root/acets-app/merge_requests/22/remove_wip", - "cancel_merge_when_pipeline_succeeds_path": "/root/acets-app/merge_requests/22/cancel_merge_when_pipeline_succeeds", - "create_issue_to_resolve_discussions_path": "/root/acets-app/issues/new?merge_request_to_resolve_discussions_of=22", - "merge_path": "/root/acets-app/merge_requests/22/merge", - "cherry_pick_in_fork_path": "/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+revert+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1", - "revert_in_fork_path": "/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+cherry-pick+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1", - "email_patches_path": "/root/acets-app/merge_requests/22.patch", - "plain_diff_path": "/root/acets-app/merge_requests/22.diff", - "status_path": "/root/acets-app/merge_requests/22.json", - "merge_check_path": "/root/acets-app/merge_requests/22/merge_check", - "ci_environments_status_url": "/root/acets-app/merge_requests/22/ci_environments_status", - "project_archived": false, - "merge_commit_message_with_description": "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", - "diverged_commits_count": 0, - "only_allow_merge_if_pipeline_succeeds": false, - "commit_change_content_path": "/root/acets-app/merge_requests/22/commit_change_content" -} + target_branch_path: '/root/acets-app/branches/master', + source_branch_path: '/root/acets-app/branches/daaaa', + conflict_resolution_ui_path: '/root/acets-app/merge_requests/22/conflicts', + remove_wip_path: '/root/acets-app/merge_requests/22/remove_wip', + cancel_merge_when_pipeline_succeeds_path: + '/root/acets-app/merge_requests/22/cancel_merge_when_pipeline_succeeds', + create_issue_to_resolve_discussions_path: + '/root/acets-app/issues/new?merge_request_to_resolve_discussions_of=22', + merge_path: '/root/acets-app/merge_requests/22/merge', + cherry_pick_in_fork_path: + '/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+revert+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1', + revert_in_fork_path: + '/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+cherry-pick+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1', + email_patches_path: '/root/acets-app/merge_requests/22.patch', + plain_diff_path: '/root/acets-app/merge_requests/22.diff', + status_path: '/root/acets-app/merge_requests/22.json', + merge_check_path: '/root/acets-app/merge_requests/22/merge_check', + ci_environments_status_url: '/root/acets-app/merge_requests/22/ci_environments_status', + project_archived: false, + merge_commit_message_with_description: + "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", + diverged_commits_count: 0, + only_allow_merge_if_pipeline_succeeds: false, + commit_change_content_path: '/root/acets-app/merge_requests/22/commit_change_content', +}; diff --git a/spec/javascripts/vue_shared/components/mock_data.js b/spec/javascripts/vue_shared/components/mock_data.js index 0d781bdca74..15b56c58c33 100644 --- a/spec/javascripts/vue_shared/components/mock_data.js +++ b/spec/javascripts/vue_shared/components/mock_data.js @@ -1,5 +1,3 @@ -/* eslint-disable */ - export const mockMetrics = [ [1493716685, '4.30859375'], [1493716745, '4.30859375'], diff --git a/spec/lib/backup/files_spec.rb b/spec/lib/backup/files_spec.rb new file mode 100644 index 00000000000..14d055cbcc1 --- /dev/null +++ b/spec/lib/backup/files_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe Backup::Files do + let(:progress) { StringIO.new } + let!(:project) { create(:project) } + + before do + allow(progress).to receive(:puts) + allow(progress).to receive(:print) + allow(FileUtils).to receive(:mkdir_p).and_return(true) + allow(FileUtils).to receive(:mv).and_return(true) + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:realpath).with("/var/gitlab-registry").and_return("/var/gitlab-registry") + allow(File).to receive(:realpath).with("/var/gitlab-registry/..").and_return("/var") + + 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 '#restore' do + subject { described_class.new('registry', '/var/gitlab-registry') } + let(:timestamp) { Time.utc(2017, 3, 22) } + + around do |example| + Timecop.freeze(timestamp) { example.run } + end + + describe 'folders with permission' do + before do + allow(subject).to receive(:run_pipeline!).and_return(true) + allow(subject).to receive(:backup_existing_files).and_return(true) + allow(Dir).to receive(:glob).with("/var/gitlab-registry/*", File::FNM_DOTMATCH).and_return(["/var/gitlab-registry/.", "/var/gitlab-registry/..", "/var/gitlab-registry/sample1"]) + end + + it 'moves all necessary files' do + allow(subject).to receive(:backup_existing_files).and_call_original + expect(FileUtils).to receive(:mv).with(["/var/gitlab-registry/sample1"], File.join(Gitlab.config.backup.path, "tmp", "registry.#{Time.now.to_i}")) + subject.restore + end + + it 'raises no errors' do + expect { subject.restore }.not_to raise_error + end + + it 'calls tar command with unlink' do + expect(subject).to receive(:run_pipeline!).with([%w(gzip -cd), %w(tar --unlink-first --recursive-unlink -C /var/gitlab-registry -xf -)], any_args) + subject.restore + end + end + + describe 'folders without permissions' do + before do + allow(FileUtils).to receive(:mv).and_raise(Errno::EACCES) + allow(subject).to receive(:run_pipeline!).and_return(true) + end + + it 'shows error message' do + expect(subject).to receive(:access_denied_error).with("/var/gitlab-registry") + subject.restore + end + end + end +end diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb index 03573c304aa..e4c1c9bafc0 100644 --- a/spec/lib/backup/repository_spec.rb +++ b/spec/lib/backup/repository_spec.rb @@ -7,6 +7,8 @@ describe Backup::Repository do before do allow(progress).to receive(:puts) allow(progress).to receive(:print) + allow(FileUtils).to receive(:mkdir_p).and_return(true) + allow(FileUtils).to receive(:mv).and_return(true) allow_any_instance_of(String).to receive(:color) do |string, _color| string @@ -68,6 +70,17 @@ describe Backup::Repository do end end end + + describe 'folders without permissions' do + before do + allow(FileUtils).to receive(:mv).and_raise(Errno::EACCES) + end + + it 'shows error message' do + expect(subject).to receive(:access_denied_error) + subject.restore + end + end end describe '#empty_repo?' do diff --git a/spec/lib/gitlab/auth/ldap/access_spec.rb b/spec/lib/gitlab/auth/ldap/access_spec.rb index 9b3916bf9e3..6b251d824f7 100644 --- a/spec/lib/gitlab/auth/ldap/access_spec.rb +++ b/spec/lib/gitlab/auth/ldap/access_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Gitlab::Auth::LDAP::Access do + include LdapHelpers + let(:access) { described_class.new user } let(:user) { create(:omniauth_user) } @@ -32,8 +34,10 @@ describe Gitlab::Auth::LDAP::Access do end context 'when the user is found' do + let(:ldap_user) { Gitlab::Auth::LDAP::Person.new(Net::LDAP::Entry.new, 'ldapmain') } + before do - allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_dn).and_return(:ldap_user) + allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_dn).and_return(ldap_user) end context 'and the user is disabled via active directory' do @@ -120,6 +124,22 @@ describe Gitlab::Auth::LDAP::Access do end end end + + context 'when the connection fails' do + before do + raise_ldap_connection_error + end + + it 'does not block the user' do + access.allowed? + + expect(user.ldap_blocked?).to be_falsey + end + + it 'denies access' do + expect(access.allowed?).to be_falsey + end + end end describe '#block_user' do diff --git a/spec/lib/gitlab/auth/ldap/adapter_spec.rb b/spec/lib/gitlab/auth/ldap/adapter_spec.rb index 10c60d792bd..3eeaf3862f6 100644 --- a/spec/lib/gitlab/auth/ldap/adapter_spec.rb +++ b/spec/lib/gitlab/auth/ldap/adapter_spec.rb @@ -124,16 +124,36 @@ describe Gitlab::Auth::LDAP::Adapter do context "when the search raises an LDAP exception" do before do + allow(adapter).to receive(:renew_connection_adapter).and_return(ldap) allow(ldap).to receive(:search) { raise Net::LDAP::Error, "some error" } allow(Rails.logger).to receive(:warn) end - it { is_expected.to eq [] } + context 'retries the operation' do + before do + stub_const("#{described_class}::MAX_SEARCH_RETRIES", 3) + end + + it 'as many times as MAX_SEARCH_RETRIES' do + expect(ldap).to receive(:search).exactly(3).times + expect { subject }.to raise_error(Gitlab::Auth::LDAP::LDAPConnectionError) + end + + context 'when no more retries' do + before do + stub_const("#{described_class}::MAX_SEARCH_RETRIES", 1) + end - it 'logs the error' do - subject - expect(Rails.logger).to have_received(:warn).with( - "LDAP search raised exception Net::LDAP::Error: some error") + it 'raises the exception' do + expect { subject }.to raise_error(Gitlab::Auth::LDAP::LDAPConnectionError) + end + + it 'logs the error' do + expect { subject }.to raise_error(Gitlab::Auth::LDAP::LDAPConnectionError) + expect(Rails.logger).to have_received(:warn).with( + "LDAP search raised exception Net::LDAP::Error: some error") + end + end end end end diff --git a/spec/lib/gitlab/auth/o_auth/user_spec.rb b/spec/lib/gitlab/auth/o_auth/user_spec.rb index 0c71f1d8ca6..64f3d09a25b 100644 --- a/spec/lib/gitlab/auth/o_auth/user_spec.rb +++ b/spec/lib/gitlab/auth/o_auth/user_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Gitlab::Auth::OAuth::User do + include LdapHelpers + let(:oauth_user) { described_class.new(auth_hash) } let(:gl_user) { oauth_user.gl_user } let(:uid) { 'my-uid' } @@ -38,10 +40,6 @@ describe Gitlab::Auth::OAuth::User do end describe '#save' do - def stub_ldap_config(messages) - allow(Gitlab::Auth::LDAP::Config).to receive_messages(messages) - end - let(:provider) { 'twitter' } describe 'when account exists on server' do @@ -269,20 +267,47 @@ describe Gitlab::Auth::OAuth::User do end context 'when an LDAP person is not found by uid' do - it 'tries to find an LDAP person by DN and adds the omniauth identity to the user' do + it 'tries to find an LDAP person by email and adds the omniauth identity to the user' do allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).and_return(nil) - allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_dn).and_return(ldap_user) + allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_email).and_return(ldap_user) + + oauth_user.save + + identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } } + expect(identities_as_hash).to match_array(result_identities(dn, uid)) + end + + context 'when also not found by email' do + it 'tries to find an LDAP person by DN and adds the omniauth identity to the user' do + allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).and_return(nil) + allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_email).and_return(nil) + allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_dn).and_return(ldap_user) + + oauth_user.save + + identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } } + expect(identities_as_hash).to match_array(result_identities(dn, uid)) + end + end + end + def result_identities(dn, uid) + [ + { provider: 'ldapmain', extern_uid: dn }, + { provider: 'twitter', extern_uid: uid } + ] + end + + context 'when there is an LDAP connection error' do + before do + raise_ldap_connection_error + end + + it 'does not save the identity' do oauth_user.save identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } } - expect(identities_as_hash) - .to match_array( - [ - { provider: 'ldapmain', extern_uid: dn }, - { provider: 'twitter', extern_uid: uid } - ] - ) + expect(identities_as_hash).to match_array([{ provider: 'twitter', extern_uid: uid }]) end end end @@ -739,4 +764,19 @@ describe Gitlab::Auth::OAuth::User do expect(oauth_user.find_user).to eql gl_user end end + + describe '#find_ldap_person' do + context 'when LDAP connection fails' do + before do + raise_ldap_connection_error + end + + it 'returns nil' do + adapter = Gitlab::Auth::LDAP::Adapter.new('ldapmain') + hash = OmniAuth::AuthHash.new(uid: 'whatever', provider: 'ldapmain') + + expect(oauth_user.send(:find_ldap_person, hash, adapter)).to be_nil + end + end + end end diff --git a/spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb b/spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb index e112e9e9e3d..5ce84c61042 100644 --- a/spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb +++ b/spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb @@ -51,4 +51,20 @@ describe Gitlab::BackgroundMigration::MigrateBuildStage, :migration, schema: 201 expect { described_class.new.perform(1, 6) } .to raise_error ActiveRecord::RecordNotUnique end + + context 'when invalid class can be loaded due to single table inheritance' do + let(:commit_status) do + jobs.create!(id: 7, commit_id: 1, project_id: 123, stage_idx: 4, + stage: 'post-deploy', status: :failed) + end + + before do + commit_status.update_column(:type, 'SomeClass') + end + + it 'does ignore single table inheritance type' do + expect { described_class.new.perform(1, 7) }.not_to raise_error + expect(jobs.find(7)).to have_attributes(stage_id: (a_value > 0)) + end + end end diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb index a6a1d9e619f..c63120b0b29 100644 --- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -137,7 +137,7 @@ describe Gitlab::BitbucketImport::Importer do it 'imports to the project disk_path' do expect(project.wiki).to receive(:repository_exists?) { false } expect(importer.gitlab_shell).to receive(:import_repository).with( - project.repository_storage_path, + project.repository_storage, project.wiki.disk_path, project.import_url + '/wiki' ) diff --git a/spec/lib/gitlab/ci/build/policy/variables_spec.rb b/spec/lib/gitlab/ci/build/policy/variables_spec.rb new file mode 100644 index 00000000000..2ce858836e3 --- /dev/null +++ b/spec/lib/gitlab/ci/build/policy/variables_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe Gitlab::Ci::Build::Policy::Variables do + set(:project) { create(:project) } + + let(:pipeline) do + build(:ci_empty_pipeline, project: project, ref: 'master', source: :push) + end + + let(:ci_build) do + build(:ci_build, pipeline: pipeline, project: project, ref: 'master') + end + + let(:seed) { double('build seed', to_resource: ci_build) } + + before do + pipeline.variables.build(key: 'CI_PROJECT_NAME', value: '') + end + + describe '#satisfied_by?' do + it 'is satisfied by at least one matching statement' do + policy = described_class.new(['$CI_PROJECT_ID', '$UNDEFINED']) + + expect(policy).to be_satisfied_by(pipeline, seed) + end + + it 'is not satisfied by an overriden empty variable' do + policy = described_class.new(['$CI_PROJECT_NAME']) + + expect(policy).not_to be_satisfied_by(pipeline, seed) + end + + it 'is satisfied by a truthy pipeline expression' do + policy = described_class.new([%($CI_PIPELINE_SOURCE == "push")]) + + expect(policy).to be_satisfied_by(pipeline, seed) + end + + it 'is not satisfied by a falsy pipeline expression' do + policy = described_class.new([%($CI_PIPELINE_SOURCE == "invalid source")]) + + expect(policy).not_to be_satisfied_by(pipeline, seed) + end + + it 'is satisfied by a truthy expression using undefined variable' do + policy = described_class.new(['$UNDEFINED == null']) + + expect(policy).to be_satisfied_by(pipeline, seed) + end + + it 'is not satisfied by a falsy expression using undefined variable' do + policy = described_class.new(['$UNDEFINED']) + + expect(policy).not_to be_satisfied_by(pipeline, seed) + end + + it 'allows to evaluate regular secret variables' do + create(:ci_variable, project: project, key: 'SECRET', value: 'my secret') + + policy = described_class.new(["$SECRET == 'my secret'"]) + + expect(policy).to be_satisfied_by(pipeline, seed) + end + + it 'does not persist neither pipeline nor build' do + described_class.new('$VAR').satisfied_by?(pipeline, seed) + + expect(pipeline).not_to be_persisted + expect(seed.to_resource).not_to be_persisted + end + end +end diff --git a/spec/lib/gitlab/ci/build/step_spec.rb b/spec/lib/gitlab/ci/build/step_spec.rb index 5a21282712a..cce4efaa069 100644 --- a/spec/lib/gitlab/ci/build/step_spec.rb +++ b/spec/lib/gitlab/ci/build/step_spec.rb @@ -5,10 +5,14 @@ describe Gitlab::Ci::Build::Step do shared_examples 'has correct script' do subject { described_class.from_commands(job) } + before do + job.run! + end + it 'fabricates an object' do expect(subject.name).to eq(:script) expect(subject.script).to eq(script) - expect(subject.timeout).to eq(job.timeout) + expect(subject.timeout).to eq(job.metadata_timeout) expect(subject.when).to eq('on_success') expect(subject.allow_failure).to be_falsey end @@ -47,6 +51,10 @@ describe Gitlab::Ci::Build::Step do subject { described_class.from_after_script(job) } + before do + job.run! + end + context 'when after_script is empty' do it 'doesn not fabricate an object' do is_expected.to be_nil @@ -59,7 +67,7 @@ describe Gitlab::Ci::Build::Step do it 'fabricates an object' do expect(subject.name).to eq(:after_script) expect(subject.script).to eq(['ls -la', 'date']) - expect(subject.timeout).to eq(job.timeout) + expect(subject.timeout).to eq(job.metadata_timeout) expect(subject.when).to eq('always') expect(subject.allow_failure).to be_truthy end diff --git a/spec/lib/gitlab/ci/config/entry/policy_spec.rb b/spec/lib/gitlab/ci/config/entry/policy_spec.rb index 5e83abf645b..08718c382b9 100644 --- a/spec/lib/gitlab/ci/config/entry/policy_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/policy_spec.rb @@ -83,6 +83,39 @@ describe Gitlab::Ci::Config::Entry::Policy do end end + context 'when specifying valid variables expressions policy' do + let(:config) { { variables: ['$VAR == null'] } } + + it 'is a correct configuraton' do + expect(entry).to be_valid + expect(entry.value).to eq(config) + end + end + + context 'when specifying variables expressions in invalid format' do + let(:config) { { variables: '$MY_VAR' } } + + it 'reports an error about invalid format' do + expect(entry.errors).to include /should be an array of strings/ + end + end + + context 'when specifying invalid variables expressions statement' do + let(:config) { { variables: ['$MY_VAR =='] } } + + it 'reports an error about invalid statement' do + expect(entry.errors).to include /invalid expression syntax/ + end + end + + context 'when specifying invalid variables expressions token' do + let(:config) { { variables: ['$MY_VAR == 123'] } } + + it 'reports an error about invalid statement' do + expect(entry.errors).to include /invalid expression syntax/ + end + end + context 'when specifying unknown policy' do let(:config) { { refs: ['master'], invalid: :something } } diff --git a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb index 2258ae83f38..8312fa47cfa 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb @@ -6,7 +6,8 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do let(:pipeline) do build(:ci_pipeline_with_one_job, project: project, - ref: 'master') + ref: 'master', + user: user) end let(:command) do @@ -42,6 +43,10 @@ describe Gitlab::Ci::Pipeline::Chain::Populate do expect(pipeline.stages.first.builds).to be_one expect(pipeline.stages.first.builds.first).not_to be_persisted end + + it 'correctly assigns user' do + expect(pipeline.builds).to all(have_attributes(user: user)) + end end context 'when pipeline is empty' do diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb index 86234dfb9e5..1ccb792d1da 100644 --- a/spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb @@ -73,6 +73,22 @@ describe Gitlab::Ci::Pipeline::Expression::Lexeme::String do expect(token).not_to be_nil expect(token.build.evaluate).to eq 'some " string' end + + it 'allows to use an empty string inside single quotes' do + scanner = StringScanner.new(%('')) + + token = described_class.scan(scanner) + + expect(token.build.evaluate).to eq '' + end + + it 'allow to use an empty string inside double quotes' do + scanner = StringScanner.new(%("")) + + token = described_class.scan(scanner) + + expect(token.build.evaluate).to eq '' + end end end diff --git a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb index 472a58599d8..6685bf5385b 100644 --- a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb @@ -1,14 +1,23 @@ require 'spec_helper' describe Gitlab::Ci::Pipeline::Expression::Statement do - let(:pipeline) { build(:ci_pipeline) } - subject do - described_class.new(text, pipeline) + described_class.new(text, variables) + end + + let(:variables) do + { 'PRESENT_VARIABLE' => 'my variable', + EMPTY_VARIABLE: '' } end - before do - pipeline.variables.build([key: 'VARIABLE', value: 'my variable']) + describe '.new' do + context 'when variables are not provided' do + it 'allows to properly initializes the statement' do + statement = described_class.new('$PRESENT_VARIABLE') + + expect(statement.evaluate).to be_nil + end + end end describe '#parse_tree' do @@ -23,18 +32,26 @@ describe Gitlab::Ci::Pipeline::Expression::Statement do context 'when expression grammar is incorrect' do table = [ - '$VAR "text"', # missing operator - '== "123"', # invalid right side - "'single quotes'", # single quotes string - '$VAR ==', # invalid right side - '12345', # unknown syntax - '' # empty statement + '$VAR "text"', # missing operator + '== "123"', # invalid left side + '"some string"', # only string provided + '$VAR ==', # invalid right side + '12345', # unknown syntax + '' # empty statement ] table.each do |syntax| - it "raises an error when syntax is `#{syntax}`" do - expect { described_class.new(syntax, pipeline).parse_tree } - .to raise_error described_class::StatementError + context "when expression grammar is #{syntax.inspect}" do + let(:text) { syntax } + + it 'aises a statement error exception' do + expect { subject.parse_tree } + .to raise_error described_class::StatementError + end + + it 'is an invalid statement' do + expect(subject).not_to be_valid + end end end end @@ -47,10 +64,14 @@ describe Gitlab::Ci::Pipeline::Expression::Statement do expect(subject.parse_tree) .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Equals end + + it 'is a valid statement' do + expect(subject).to be_valid + end end context 'when using a single token' do - let(:text) { '$VARIABLE' } + let(:text) { '$PRESENT_VARIABLE' } it 'returns a single token instance' do expect(subject.parse_tree) @@ -62,14 +83,17 @@ describe Gitlab::Ci::Pipeline::Expression::Statement do describe '#evaluate' do statements = [ - ['$VARIABLE == "my variable"', true], - ["$VARIABLE == 'my variable'", true], - ['"my variable" == $VARIABLE', true], - ['$VARIABLE == null', false], - ['$VAR == null', true], - ['null == $VAR', true], - ['$VARIABLE', 'my variable'], - ['$VAR', nil] + ['$PRESENT_VARIABLE == "my variable"', true], + ["$PRESENT_VARIABLE == 'my variable'", true], + ['"my variable" == $PRESENT_VARIABLE', true], + ['$PRESENT_VARIABLE == null', false], + ['$EMPTY_VARIABLE == null', false], + ['"" == $EMPTY_VARIABLE', true], + ['$EMPTY_VARIABLE', ''], + ['$UNDEFINED_VARIABLE == null', true], + ['null == $UNDEFINED_VARIABLE', true], + ['$PRESENT_VARIABLE', 'my variable'], + ['$UNDEFINED_VARIABLE', nil] ] statements.each do |expression, value| @@ -82,4 +106,25 @@ describe Gitlab::Ci::Pipeline::Expression::Statement do end end end + + describe '#truthful?' do + statements = [ + ['$PRESENT_VARIABLE == "my variable"', true], + ["$PRESENT_VARIABLE == 'no match'", false], + ['$UNDEFINED_VARIABLE == null', true], + ['$PRESENT_VARIABLE', true], + ['$UNDEFINED_VARIABLE', false], + ['$EMPTY_VARIABLE', false] + ] + + statements.each do |expression, value| + context "when using expression `#{expression}`" do + let(:text) { expression } + + it "returns `#{value.inspect}`" do + expect(subject.truthful?).to eq value + end + end + end + end end diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb index 116573379e0..fffa727c2ed 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -21,16 +21,6 @@ describe Gitlab::Ci::Pipeline::Seed::Build do end end - describe '#user=' do - let(:user) { build(:user) } - - it 'assignes user to a build' do - subject.user = user - - expect(subject.attributes).to include(user: user) - end - end - describe '#to_resource' do it 'returns a valid build resource' do expect(subject.to_resource).to be_a(::Ci::Build) diff --git a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb index 8f0bf40d624..eb1b285c7bd 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb @@ -95,16 +95,6 @@ describe Gitlab::Ci::Pipeline::Seed::Stage do end end - describe '#user=' do - let(:user) { build(:user) } - - it 'assignes relevant pipeline attributes' do - subject.user = user - - expect(subject.seeds.map(&:attributes)).to all(include(user: user)) - end - end - describe '#to_resource' do it 'builds a valid stage object with all builds' do subject.to_resource.save! diff --git a/spec/lib/gitlab/ci/variables/collection/item_spec.rb b/spec/lib/gitlab/ci/variables/collection/item_spec.rb index cc1257484d2..bf9208f1ff4 100644 --- a/spec/lib/gitlab/ci/variables/collection/item_spec.rb +++ b/spec/lib/gitlab/ci/variables/collection/item_spec.rb @@ -46,9 +46,13 @@ describe Gitlab::Ci::Variables::Collection::Item do end end - describe '#to_hash' do - it 'returns a hash representation of a collection item' do - expect(described_class.new(**variable).to_hash).to eq variable + describe '#to_runner_variable' do + it 'returns a runner-compatible hash representation' do + runner_variable = described_class + .new(**variable) + .to_runner_variable + + expect(runner_variable).to eq variable end end end diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb index 90b6e178242..cb2f7718c9c 100644 --- a/spec/lib/gitlab/ci/variables/collection_spec.rb +++ b/spec/lib/gitlab/ci/variables/collection_spec.rb @@ -7,7 +7,7 @@ describe Gitlab::Ci::Variables::Collection do collection = described_class.new([variable]) - expect(collection.first.to_hash).to eq variable + expect(collection.first.to_runner_variable).to eq variable end it 'can be initialized without an argument' do @@ -96,4 +96,19 @@ describe Gitlab::Ci::Variables::Collection do .to eq [{ key: 'TEST', value: 1, public: true }] end end + + describe '#to_hash' do + it 'returns regular hash in valid order without duplicates' do + collection = described_class.new + .append(key: 'TEST1', value: 'test-1') + .append(key: 'TEST2', value: 'test-2') + .append(key: 'TEST1', value: 'test-3') + + expect(collection.to_hash).to eq('TEST1' => 'test-3', + 'TEST2' => 'test-2') + + expect(collection.to_hash).to include(TEST1: 'test-3') + expect(collection.to_hash).not_to include(TEST1: 'test-1') + end + end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index fbc2af29b98..ecb16daec96 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -1311,6 +1311,14 @@ module Gitlab Gitlab::Ci::YamlProcessor.new(config) end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec dependencies should be an array of strings") end + + it 'returns errors if pipeline variables expression is invalid' do + config = YAML.dump({ rspec: { script: 'test', only: { variables: ['== null'] } } }) + + expect { Gitlab::Ci::YamlProcessor.new(config) } + .to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, + 'jobs:rspec:only variables invalid expression syntax') + end end describe "Validate configuration templates" do diff --git a/spec/lib/gitlab/git/checksum_spec.rb b/spec/lib/gitlab/git/checksum_spec.rb new file mode 100644 index 00000000000..8ff310905bf --- /dev/null +++ b/spec/lib/gitlab/git/checksum_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe Gitlab::Git::Checksum, seed_helper: true do + let(:storage) { 'default' } + + it 'raises Gitlab::Git::Repository::NoRepository when there is no repo' do + checksum = described_class.new(storage, 'nonexistent-repo') + + expect { checksum.calculate }.to raise_error Gitlab::Git::Repository::NoRepository + end + + it 'pretends that checksum is 000000... when the repo is empty' do + FileUtils.rm_rf(File.join(SEED_STORAGE_PATH, 'empty-repo.git')) + + system(git_env, *%W(#{Gitlab.config.git.bin_path} init --bare empty-repo.git), + chdir: SEED_STORAGE_PATH, + out: '/dev/null', + err: '/dev/null') + + checksum = described_class.new(storage, 'empty-repo') + + expect(checksum.calculate).to eq '0000000000000000000000000000000000000000' + end + + it 'raises Gitlab::Git::Repository::Failure when shelling out to git return non-zero status' do + checksum = described_class.new(storage, 'gitlab-git-test') + + allow(checksum).to receive(:popen).and_return(['output', nil]) + + expect { checksum.calculate }.to raise_error Gitlab::Git::Checksum::Failure + end + + it 'calculates the checksum when there is a repo' do + checksum = described_class.new(storage, 'gitlab-git-test') + + expect(checksum.calculate).to eq '54f21be4c32c02f6788d72207fa03ad3bce725e4' + end +end diff --git a/spec/lib/gitlab/git/gitlab_projects_spec.rb b/spec/lib/gitlab/git/gitlab_projects_spec.rb index dfccc15a4f3..8b715d717c1 100644 --- a/spec/lib/gitlab/git/gitlab_projects_spec.rb +++ b/spec/lib/gitlab/git/gitlab_projects_spec.rb @@ -16,7 +16,7 @@ describe Gitlab::Git::GitlabProjects do let(:tmp_repos_path) { TestEnv.repos_path } let(:repo_name) { project.disk_path + '.git' } let(:tmp_repo_path) { File.join(tmp_repos_path, repo_name) } - let(:gl_projects) { build_gitlab_projects(tmp_repos_path, repo_name) } + let(:gl_projects) { build_gitlab_projects(TestEnv::REPOS_STORAGE, repo_name) } describe '#initialize' do it { expect(gl_projects.shard_path).to eq(tmp_repos_path) } @@ -223,11 +223,12 @@ describe Gitlab::Git::GitlabProjects do end describe '#fork_repository' do + let(:dest_repos) { TestEnv::REPOS_STORAGE } let(:dest_repos_path) { tmp_repos_path } let(:dest_repo_name) { File.join('@hashed', 'aa', 'bb', 'xyz.git') } let(:dest_repo) { File.join(dest_repos_path, dest_repo_name) } - subject { gl_projects.fork_repository(dest_repos_path, dest_repo_name) } + subject { gl_projects.fork_repository(dest_repos, dest_repo_name) } before do FileUtils.mkdir_p(dest_repos_path) @@ -268,7 +269,12 @@ describe Gitlab::Git::GitlabProjects do # that is not very straight-forward so I'm leaving this test here for now till # https://gitlab.com/gitlab-org/gitlab-ce/issues/41393 is fixed. context 'different storages' do - let(:dest_repos_path) { File.join(File.dirname(tmp_repos_path), 'alternative') } + let(:dest_repos) { 'alternative' } + let(:dest_repos_path) { File.join(File.dirname(tmp_repos_path), dest_repos) } + + before do + stub_storage_settings(dest_repos => { 'path' => dest_repos_path }) + end it 'forks the repo' do is_expected.to be_truthy diff --git a/spec/lib/gitlab/git/gitmodules_parser_spec.rb b/spec/lib/gitlab/git/gitmodules_parser_spec.rb index 143aa2218c9..6fd2b33486b 100644 --- a/spec/lib/gitlab/git/gitmodules_parser_spec.rb +++ b/spec/lib/gitlab/git/gitmodules_parser_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Gitlab::Git::GitmodulesParser do it 'should parse a .gitmodules file correctly' do - parser = described_class.new(<<-'GITMODULES'.strip_heredoc) + data = <<~GITMODULES [submodule "vendor/libgit2"] path = vendor/libgit2 [submodule "vendor/libgit2"] @@ -16,6 +16,7 @@ describe Gitlab::Git::GitmodulesParser do url = https://example.com/another/project GITMODULES + parser = described_class.new(data.gsub("\n", "\r\n")) modules = parser.parse expect(modules).to eq({ diff --git a/spec/lib/gitlab/git/env_spec.rb b/spec/lib/gitlab/git/hook_env_spec.rb index 03836d49518..e6aa5ad8c90 100644 --- a/spec/lib/gitlab/git/env_spec.rb +++ b/spec/lib/gitlab/git/hook_env_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' -describe Gitlab::Git::Env do +describe Gitlab::Git::HookEnv do + let(:gl_repository) { 'project-123' } + describe ".set" do context 'with RequestStore.store disabled' do before do @@ -8,9 +10,9 @@ describe Gitlab::Git::Env do end it 'does not store anything' do - described_class.set(GIT_OBJECT_DIRECTORY: 'foo') + described_class.set(gl_repository, GIT_OBJECT_DIRECTORY_RELATIVE: 'foo') - expect(described_class.all).to be_empty + expect(described_class.all(gl_repository)).to be_empty end end @@ -21,15 +23,19 @@ describe Gitlab::Git::Env do it 'whitelist some `GIT_*` variables and stores them using RequestStore' do described_class.set( - GIT_OBJECT_DIRECTORY: 'foo', - GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar', + gl_repository, + GIT_OBJECT_DIRECTORY_RELATIVE: 'foo', + GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE: 'bar', GIT_EXEC_PATH: 'baz', PATH: '~/.bin:/bin') - expect(described_class[:GIT_OBJECT_DIRECTORY]).to eq('foo') - expect(described_class[:GIT_ALTERNATE_OBJECT_DIRECTORIES]).to eq('bar') - expect(described_class[:GIT_EXEC_PATH]).to be_nil - expect(described_class[:bar]).to be_nil + git_env = described_class.all(gl_repository) + + expect(git_env[:GIT_OBJECT_DIRECTORY_RELATIVE]).to eq('foo') + expect(git_env[:GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE]).to eq('bar') + expect(git_env[:GIT_EXEC_PATH]).to be_nil + expect(git_env[:PATH]).to be_nil + expect(git_env[:bar]).to be_nil end end end @@ -39,14 +45,15 @@ describe Gitlab::Git::Env do before do allow(RequestStore).to receive(:active?).and_return(true) described_class.set( - GIT_OBJECT_DIRECTORY: 'foo', - GIT_ALTERNATE_OBJECT_DIRECTORIES: ['bar']) + gl_repository, + GIT_OBJECT_DIRECTORY_RELATIVE: 'foo', + GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE: ['bar']) end it 'returns an env hash' do - expect(described_class.all).to eq({ - 'GIT_OBJECT_DIRECTORY' => 'foo', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => ['bar'] + expect(described_class.all(gl_repository)).to eq({ + 'GIT_OBJECT_DIRECTORY_RELATIVE' => 'foo', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => ['bar'] }) end end @@ -56,8 +63,8 @@ describe Gitlab::Git::Env do context 'with RequestStore.store enabled' do using RSpec::Parameterized::TableSyntax - let(:key) { 'GIT_OBJECT_DIRECTORY' } - subject { described_class.to_env_hash } + let(:key) { 'GIT_OBJECT_DIRECTORY_RELATIVE' } + subject { described_class.to_env_hash(gl_repository) } where(:input, :output) do nil | nil @@ -70,7 +77,7 @@ describe Gitlab::Git::Env do with_them do before do allow(RequestStore).to receive(:active?).and_return(true) - described_class.set(key.to_sym => input) + described_class.set(gl_repository, key.to_sym => input) end it 'puts the right value in the hash' do @@ -84,47 +91,25 @@ describe Gitlab::Git::Env do end end - describe ".[]" do - context 'with RequestStore.store enabled' do - before do - allow(RequestStore).to receive(:active?).and_return(true) - end - - before do - described_class.set( - GIT_OBJECT_DIRECTORY: 'foo', - GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar') - end - - it 'returns a stored value for an existing key' do - expect(described_class[:GIT_OBJECT_DIRECTORY]).to eq('foo') - end - - it 'returns nil for an non-existing key' do - expect(described_class[:foo]).to be_nil - end - end - end - describe 'thread-safety' do context 'with RequestStore.store enabled' do before do allow(RequestStore).to receive(:active?).and_return(true) - described_class.set(GIT_OBJECT_DIRECTORY: 'foo') + described_class.set(gl_repository, GIT_OBJECT_DIRECTORY_RELATIVE: 'foo') end it 'is thread-safe' do another_thread = Thread.new do - described_class.set(GIT_OBJECT_DIRECTORY: 'bar') + described_class.set(gl_repository, GIT_OBJECT_DIRECTORY_RELATIVE: 'bar') Thread.stop - described_class[:GIT_OBJECT_DIRECTORY] + described_class.all(gl_repository)[:GIT_OBJECT_DIRECTORY_RELATIVE] end # Ensure another_thread runs first sleep 0.1 until another_thread.stop? - expect(described_class[:GIT_OBJECT_DIRECTORY]).to eq('foo') + expect(described_class.all(gl_repository)[:GIT_OBJECT_DIRECTORY_RELATIVE]).to eq('foo') another_thread.run expect(another_thread.value).to eq('bar') diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 0e315b3f49e..5cbe2808d0b 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -120,7 +120,7 @@ describe Gitlab::Git::Repository, seed_helper: true do describe 'alternates keyword argument' do context 'with no Git env stored' do before do - allow(Gitlab::Git::Env).to receive(:all).and_return({}) + allow(Gitlab::Git::HookEnv).to receive(:all).and_return({}) end it "is passed an empty array" do @@ -132,7 +132,7 @@ describe Gitlab::Git::Repository, seed_helper: true do context 'with absolute and relative Git object dir envvars stored' do before do - allow(Gitlab::Git::Env).to receive(:all).and_return({ + allow(Gitlab::Git::HookEnv).to receive(:all).and_return({ 'GIT_OBJECT_DIRECTORY_RELATIVE' => './objects/foo', 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => ['./objects/bar', './objects/baz'], 'GIT_OBJECT_DIRECTORY' => 'ignored', @@ -148,22 +148,6 @@ describe Gitlab::Git::Repository, seed_helper: true do repository.rugged end end - - context 'with only absolute Git object dir envvars stored' do - before do - allow(Gitlab::Git::Env).to receive(:all).and_return({ - 'GIT_OBJECT_DIRECTORY' => 'foo', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => %w[bar baz], - 'GIT_OTHER' => 'another_env' - }) - end - - it "is passed the absolute object dir envvars as is" do - expect(Rugged::Repository).to receive(:new).with(repository.path, alternates: %w[foo bar baz]) - - repository.rugged - end - end end end diff --git a/spec/lib/gitlab/git/rev_list_spec.rb b/spec/lib/gitlab/git/rev_list_spec.rb index 4e0ee206219..32ec1e029c8 100644 --- a/spec/lib/gitlab/git/rev_list_spec.rb +++ b/spec/lib/gitlab/git/rev_list_spec.rb @@ -3,17 +3,6 @@ require 'spec_helper' describe Gitlab::Git::RevList do let(:repository) { create(:project, :repository).repository.raw } let(:rev_list) { described_class.new(repository, newrev: 'newrev') } - let(:env_hash) do - { - 'GIT_OBJECT_DIRECTORY' => 'foo', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'bar' - } - end - let(:command_env) { { 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'foo:bar' } } - - before do - allow(Gitlab::Git::Env).to receive(:all).and_return(env_hash) - end def args_for_popen(args_list) [Gitlab.config.git.bin_path, 'rev-list', *args_list] @@ -23,7 +12,7 @@ describe Gitlab::Git::RevList do params = [ args_for_popen(additional_args), repository.path, - command_env, + {}, hash_including(lazy_block: with_lazy_block ? anything : nil) ] diff --git a/spec/lib/gitlab/gitaly_client/util_spec.rb b/spec/lib/gitlab/gitaly_client/util_spec.rb index d1e0136f8c1..550db6db6d9 100644 --- a/spec/lib/gitlab/gitaly_client/util_spec.rb +++ b/spec/lib/gitlab/gitaly_client/util_spec.rb @@ -7,16 +7,19 @@ describe Gitlab::GitalyClient::Util do let(:gl_repository) { 'project-1' } let(:git_object_directory) { '.git/objects' } let(:git_alternate_object_directory) { ['/dir/one', '/dir/two'] } + let(:git_env) do + { + 'GIT_OBJECT_DIRECTORY_RELATIVE' => git_object_directory, + 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => git_alternate_object_directory + } + end subject do described_class.repository(repository_storage, relative_path, gl_repository) end it 'creates a Gitaly::Repository with the given data' do - allow(Gitlab::Git::Env).to receive(:[]).with('GIT_OBJECT_DIRECTORY_RELATIVE') - .and_return(git_object_directory) - allow(Gitlab::Git::Env).to receive(:[]).with('GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE') - .and_return(git_alternate_object_directory) + allow(Gitlab::Git::HookEnv).to receive(:all).with(gl_repository).and_return(git_env) expect(subject).to be_a(Gitaly::Repository) expect(subject.storage_name).to eq(repository_storage) diff --git a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb index 1f0f1fdd7da..879b1d9fb0f 100644 --- a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb @@ -9,7 +9,7 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do :project, import_url: 'foo.git', import_source: 'foo/bar', - repository_storage_path: 'foo', + repository_storage: 'foo', disk_path: 'foo', repository: repository, create_wiki: true diff --git a/spec/lib/gitlab/http_spec.rb b/spec/lib/gitlab/http_spec.rb index b0bc081a3c8..d0dadfa78da 100644 --- a/spec/lib/gitlab/http_spec.rb +++ b/spec/lib/gitlab/http_spec.rb @@ -12,11 +12,11 @@ describe Gitlab::HTTP do end it 'deny requests to localhost' do - expect { described_class.get('http://localhost:3003') }.to raise_error(URI::InvalidURIError) + expect { described_class.get('http://localhost:3003') }.to raise_error(Gitlab::HTTP::BlockedUrlError) end it 'deny requests to private network' do - expect { described_class.get('http://192.168.1.2:3003') }.to raise_error(URI::InvalidURIError) + expect { described_class.get('http://192.168.1.2:3003') }.to raise_error(Gitlab::HTTP::BlockedUrlError) end context 'if allow_local_requests set to true' do @@ -41,7 +41,7 @@ describe Gitlab::HTTP do context 'if allow_local_requests set to false' do it 'override the global value and ban requests to localhost or private network' do - expect { described_class.get('http://localhost:3003', allow_local_requests: false) }.to raise_error(URI::InvalidURIError) + expect { described_class.get('http://localhost:3003', allow_local_requests: false) }.to raise_error(Gitlab::HTTP::BlockedUrlError) end end end diff --git a/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb b/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb new file mode 100644 index 00000000000..ed54d87de4a --- /dev/null +++ b/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe Gitlab::ImportExport::AfterExportStrategies::BaseAfterExportStrategy do + let!(:service) { described_class.new } + let!(:project) { create(:project, :with_export) } + let(:shared) { project.import_export_shared } + let!(:user) { create(:user) } + + describe '#execute' do + before do + allow(service).to receive(:strategy_execute) + end + + it 'returns if project exported file is not found' do + allow(project).to receive(:export_project_path).and_return(nil) + + expect(service).not_to receive(:strategy_execute) + + service.execute(user, project) + end + + it 'creates a lock file in the export dir' do + allow(service).to receive(:delete_after_export_lock) + + service.execute(user, project) + + expect(lock_path_exist?).to be_truthy + end + + context 'when the method succeeds' do + it 'removes the lock file' do + service.execute(user, project) + + expect(lock_path_exist?).to be_falsey + end + end + + context 'when the method fails' do + before do + allow(service).to receive(:strategy_execute).and_call_original + end + + context 'when validation fails' do + before do + allow(service).to receive(:invalid?).and_return(true) + end + + it 'does not create the lock file' do + expect(service).not_to receive(:create_or_update_after_export_lock) + + service.execute(user, project) + end + + it 'does not execute main logic' do + expect(service).not_to receive(:strategy_execute) + + service.execute(user, project) + end + + it 'logs validation errors in shared context' do + expect(service).to receive(:log_validation_errors) + + service.execute(user, project) + end + end + + context 'when an exception is raised' do + it 'removes the lock' do + expect { service.execute(user, project) }.to raise_error(NotImplementedError) + + expect(lock_path_exist?).to be_falsey + end + end + end + end + + describe '#log_validation_errors' do + it 'add the message to the shared context' do + errors = %w(test_message test_message2) + + allow(service).to receive(:invalid?).and_return(true) + allow(service.errors).to receive(:full_messages).and_return(errors) + + expect(shared).to receive(:add_error_message).twice.and_call_original + + service.execute(user, project) + + expect(shared.errors).to eq errors + end + end + + describe '#to_json' do + it 'adds the current strategy class to the serialized attributes' do + params = { param1: 1 } + result = params.merge(klass: described_class.to_s).to_json + + expect(described_class.new(params).to_json).to eq result + end + end + + def lock_path_exist? + File.exist?(described_class.lock_file_path(project)) + end +end diff --git a/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb b/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb new file mode 100644 index 00000000000..5fe57d9987b --- /dev/null +++ b/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy do + let(:example_url) { 'http://www.example.com' } + let(:strategy) { subject.new(url: example_url, http_method: 'post') } + let!(:project) { create(:project, :with_export) } + let!(:user) { build(:user) } + + subject { described_class } + + describe 'validations' do + it 'only POST and PUT method allowed' do + %w(POST post PUT put).each do |method| + expect(subject.new(url: example_url, http_method: method)).to be_valid + end + + expect(subject.new(url: example_url, http_method: 'whatever')).not_to be_valid + end + + it 'onyl allow urls as upload urls' do + expect(subject.new(url: example_url)).to be_valid + expect(subject.new(url: 'whatever')).not_to be_valid + end + end + + describe '#execute' do + it 'removes the exported project file after the upload' do + allow(strategy).to receive(:send_file) + allow(strategy).to receive(:handle_response_error) + + expect(project).to receive(:remove_exported_project_file) + + strategy.execute(user, project) + end + end +end diff --git a/spec/lib/gitlab/import_export/after_export_strategy_builder_spec.rb b/spec/lib/gitlab/import_export/after_export_strategy_builder_spec.rb new file mode 100644 index 00000000000..bf727285a9f --- /dev/null +++ b/spec/lib/gitlab/import_export/after_export_strategy_builder_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe Gitlab::ImportExport::AfterExportStrategyBuilder do + let!(:strategies_namespace) { 'Gitlab::ImportExport::AfterExportStrategies' } + + describe '.build!' do + context 'when klass param is' do + it 'null it returns the default strategy' do + expect(described_class.build!(nil).class).to eq described_class.default_strategy + end + + it 'not a valid class it raises StrategyNotFoundError exception' do + expect { described_class.build!('Whatever') }.to raise_error(described_class::StrategyNotFoundError) + end + + it 'not a descendant of AfterExportStrategy' do + expect { described_class.build!('User') }.to raise_error(described_class::StrategyNotFoundError) + end + end + + it 'initializes strategy with attributes param' do + params = { param1: 1, param2: 2, param3: 3 } + + strategy = described_class.build!("#{strategies_namespace}::DownloadNotificationStrategy", params) + + params.each { |k, v| expect(strategy.public_send(k)).to eq v } + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index a204a8f1ffe..b675d5dc031 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -18,6 +18,7 @@ issues: - metrics - timelogs - issue_assignees +- closed_by events: - author - project diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 0716852f57f..f949a23ffbb 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -15,6 +15,7 @@ Issue: - updated_by_id - confidential - closed_at +- closed_by_id - due_date - moved_to_id - lock_version diff --git a/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb b/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb index 6721e02fb85..61eb059a731 100644 --- a/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb +++ b/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb @@ -38,7 +38,9 @@ describe Gitlab::Metrics::SidekiqMetricsExporter do expect(::WEBrick::HTTPServer).to have_received(:new).with( Port: port, - BindAddress: address + BindAddress: address, + Logger: anything, + AccessLog: anything ) end end diff --git a/spec/lib/gitlab/performance_bar_spec.rb b/spec/lib/gitlab/performance_bar_spec.rb index b8a2267f1a4..f480376acb4 100644 --- a/spec/lib/gitlab/performance_bar_spec.rb +++ b/spec/lib/gitlab/performance_bar_spec.rb @@ -25,6 +25,12 @@ describe Gitlab::PerformanceBar do expect(described_class.enabled?(nil)).to be_falsy end + it 'returns true when given user is an admin' do + user = build_stubbed(:user, :admin) + + expect(described_class.enabled?(user)).to be_truthy + end + it 'returns false when allowed_group_id is nil' do expect(described_class).to receive(:allowed_group_id).and_return(nil) diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index ea5ce58e34b..7ff2c0639ec 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Shell do allow(Project).to receive(:find).and_return(project) allow(gitlab_shell).to receive(:gitlab_projects) - .with(project.repository_storage_path, project.disk_path + '.git') + .with(project.repository_storage, project.disk_path + '.git') .and_return(gitlab_projects) end @@ -487,21 +487,21 @@ describe Gitlab::Shell do describe '#fork_repository' do subject do gitlab_shell.fork_repository( - project.repository_storage_path, + project.repository_storage, project.disk_path, - 'new/storage', + 'nfs-file05', 'fork/path' ) end it 'returns true when the command succeeds' do - expect(gitlab_projects).to receive(:fork_repository).with('new/storage', 'fork/path.git') { true } + expect(gitlab_projects).to receive(:fork_repository).with('nfs-file05', 'fork/path.git') { true } is_expected.to be_truthy end it 'return false when the command fails' do - expect(gitlab_projects).to receive(:fork_repository).with('new/storage', 'fork/path.git') { false } + expect(gitlab_projects).to receive(:fork_repository).with('nfs-file05', 'fork/path.git') { false } is_expected.to be_falsy end @@ -661,7 +661,7 @@ describe Gitlab::Shell do it 'returns true when the command succeeds' do expect(gitlab_projects).to receive(:import_project).with(import_url, timeout) { true } - result = gitlab_shell.import_repository(project.repository_storage_path, project.disk_path, import_url) + result = gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url) expect(result).to be_truthy end @@ -671,7 +671,7 @@ describe Gitlab::Shell do expect(gitlab_projects).to receive(:import_project) { false } expect do - gitlab_shell.import_repository(project.repository_storage_path, project.disk_path, import_url) + gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url) end.to raise_error(Gitlab::Shell::Error, "error") end end diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb index 2d35b026485..a3b3dc3be6d 100644 --- a/spec/lib/gitlab/url_blocker_spec.rb +++ b/spec/lib/gitlab/url_blocker_spec.rb @@ -74,13 +74,13 @@ describe Gitlab::UrlBlocker do expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git')).to be false end - context 'when allow_private_networks is' do - let(:private_networks) { ['192.168.1.2', '10.0.0.2', '172.16.0.2'] } + context 'when allow_local_network is' do + let(:local_ips) { ['192.168.1.2', '10.0.0.2', '172.16.0.2'] } let(:fake_domain) { 'www.fakedomain.fake' } context 'true (default)' do it 'does not block urls from private networks' do - private_networks.each do |ip| + local_ips.each do |ip| stub_domain_resolv(fake_domain, ip) expect(described_class).not_to be_blocked_url("http://#{fake_domain}") @@ -94,14 +94,14 @@ describe Gitlab::UrlBlocker do context 'false' do it 'blocks urls from private networks' do - private_networks.each do |ip| + local_ips.each do |ip| stub_domain_resolv(fake_domain, ip) - expect(described_class).to be_blocked_url("http://#{fake_domain}", allow_private_networks: false) + expect(described_class).to be_blocked_url("http://#{fake_domain}", allow_local_network: false) unstub_domain_resolv - expect(described_class).to be_blocked_url("http://#{ip}", allow_private_networks: false) + expect(described_class).to be_blocked_url("http://#{ip}", allow_local_network: false) end end end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 138d21ede97..9e6aa109a4b 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -12,6 +12,14 @@ describe Gitlab::UsageData do create(:service, project: projects[0], type: 'SlackSlashCommandsService', active: true) create(:service, project: projects[1], type: 'SlackService', active: true) create(:service, project: projects[2], type: 'SlackService', active: true) + + gcp_cluster = create(:cluster, :provided_by_gcp) + create(:cluster, :provided_by_user) + create(:cluster, :provided_by_user, :disabled) + create(:clusters_applications_helm, :installed, cluster: gcp_cluster) + create(:clusters_applications_ingress, :installed, cluster: gcp_cluster) + create(:clusters_applications_prometheus, :installed, cluster: gcp_cluster) + create(:clusters_applications_runner, :installed, cluster: gcp_cluster) end subject { described_class.data } @@ -64,6 +72,12 @@ describe Gitlab::UsageData do clusters clusters_enabled clusters_disabled + clusters_platforms_gke + clusters_platforms_user + clusters_applications_helm + clusters_applications_ingress + clusters_applications_prometheus + clusters_applications_runner in_review_folder groups issues @@ -97,6 +111,15 @@ describe Gitlab::UsageData do expect(count_data[:projects_jira_active]).to eq(2) expect(count_data[:projects_slack_notifications_active]).to eq(2) expect(count_data[:projects_slack_slash_active]).to eq(1) + + expect(count_data[:clusters_enabled]).to eq(6) + expect(count_data[:clusters_disabled]).to eq(1) + expect(count_data[:clusters_platforms_gke]).to eq(1) + expect(count_data[:clusters_platforms_user]).to eq(1) + expect(count_data[:clusters_applications_helm]).to eq(1) + expect(count_data[:clusters_applications_ingress]).to eq(1) + expect(count_data[:clusters_applications_prometheus]).to eq(1) + expect(count_data[:clusters_applications_runner]).to eq(1) end end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index d67bfb37be7..2b3ffb2d7c0 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -275,12 +275,14 @@ describe Gitlab::Workhorse do describe '.git_http_ok' do let(:user) { create(:user) } + let(:repo_path) { 'ignored but not allowed to be empty in gitlab-workhorse' } let(:action) { 'info_refs' } let(:params) do { GL_ID: "user-#{user.id}", GL_USERNAME: user.username, GL_REPOSITORY: "project-#{project.id}", + RepoPath: repo_path, ShowAllRefs: false } end @@ -295,6 +297,7 @@ describe Gitlab::Workhorse do GL_ID: "user-#{user.id}", GL_USERNAME: user.username, GL_REPOSITORY: "wiki-#{project.id}", + RepoPath: repo_path, ShowAllRefs: false } end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 83c33797bbc..971a88e9ee9 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -389,6 +389,36 @@ describe Notify do end end end + + describe 'that have new commits' do + let(:push_user) { create(:user) } + + subject do + described_class.push_to_merge_request_email(recipient.id, merge_request.id, push_user.id, new_commits: merge_request.commits) + end + + it_behaves_like 'a multiple recipients email' + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { merge_request } + end + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'an unsubscribeable thread' + + it 'is sent as the push user' do + sender = subject.header[:from].addrs[0] + + expect(sender.display_name).to eq(push_user.name) + expect(sender.address).to eq(gitlab_sender) + end + + it 'has the correct subject and body' do + aggregate_failures do + is_expected.to have_referable_subject(merge_request, reply: true) + is_expected.to have_body_text("#{push_user.name} pushed new commits") + is_expected.to have_body_text(project_merge_request_path(project, merge_request)) + end + end + end end context 'for issue notes' do diff --git a/spec/mailers/previews/notify_preview.rb b/spec/mailers/previews/notify_preview.rb index 580f0d56a92..43c3c89f140 100644 --- a/spec/mailers/previews/notify_preview.rb +++ b/spec/mailers/previews/notify_preview.rb @@ -65,7 +65,7 @@ class NotifyPreview < ActionMailer::Preview end def merge_request - @merge_request ||= project.merge_requests.find_by(source_branch: 'master', target_branch: 'feature') + @merge_request ||= project.merge_requests.first end def user diff --git a/spec/models/ci/artifact_blob_spec.rb b/spec/models/ci/artifact_blob_spec.rb index 4e72d9d748e..0014bbcf9f5 100644 --- a/spec/models/ci/artifact_blob_spec.rb +++ b/spec/models/ci/artifact_blob_spec.rb @@ -65,6 +65,19 @@ describe Ci::ArtifactBlob do expect(url).not_to be_nil expect(url).to eq("http://#{project.namespace.path}.#{Gitlab.config.pages.host}/-/#{project.path}/-/jobs/#{build.id}/artifacts/#{path}") end + + context 'when port is configured' do + let(:port) { 1234 } + + it 'returns an URL with port number' do + allow(Gitlab.config.pages).to receive(:url).and_return("#{Gitlab.config.pages.url}:#{port}") + + url = subject.external_url(build.project, build) + + expect(url).not_to be_nil + expect(url).to eq("http://#{project.namespace.path}.#{Gitlab.config.pages.host}:#{port}/-/#{project.path}/-/jobs/#{build.id}/artifacts/#{path}") + end + end end end diff --git a/spec/models/ci/build_metadata_spec.rb b/spec/models/ci/build_metadata_spec.rb new file mode 100644 index 00000000000..268561ee941 --- /dev/null +++ b/spec/models/ci/build_metadata_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe Ci::BuildMetadata do + set(:user) { create(:user) } + set(:group) { create(:group, :access_requestable) } + set(:project) { create(:project, :repository, group: group, build_timeout: 2000) } + + set(:pipeline) do + create(:ci_pipeline, project: project, + sha: project.commit.id, + ref: project.default_branch, + status: 'success') + end + + let(:build) { create(:ci_build, pipeline: pipeline) } + let(:build_metadata) { create(:ci_build_metadata, build: build) } + + describe '#update_timeout_state' do + subject { build_metadata } + + context 'when runner is not assigned to the job' do + it "doesn't change timeout value" do + expect { subject.update_timeout_state }.not_to change { subject.reload.timeout } + end + + it "doesn't change timeout_source value" do + expect { subject.update_timeout_state }.not_to change { subject.reload.timeout_source } + end + end + + context 'when runner is assigned to the job' do + before do + build.update_attributes(runner: runner) + end + + context 'when runner timeout is lower than project timeout' do + let(:runner) { create(:ci_runner, maximum_timeout: 1900) } + + it 'sets runner timeout' do + expect { subject.update_timeout_state }.to change { subject.reload.timeout }.to(1900) + end + + it 'sets runner_timeout_source' do + expect { subject.update_timeout_state }.to change { subject.reload.timeout_source }.to('runner_timeout_source') + end + end + + context 'when runner timeout is higher than project timeout' do + let(:runner) { create(:ci_runner, maximum_timeout: 2100) } + + it 'sets project timeout' do + expect { subject.update_timeout_state }.to change { subject.reload.timeout }.to(2000) + end + + it 'sets project_timeout_source' do + expect { subject.update_timeout_state }.to change { subject.reload.timeout_source }.to('project_timeout_source') + end + end + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 7d935cf8d76..a12717835b0 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1271,12 +1271,6 @@ describe Ci::Build do end describe 'project settings' do - describe '#timeout' do - it 'returns project timeout configuration' do - expect(build.timeout).to eq(project.build_timeout) - end - end - describe '#allow_git_fetch' do it 'return project allow_git_fetch configuration' do expect(build.allow_git_fetch).to eq(project.build_allow_git_fetch) @@ -1469,24 +1463,24 @@ describe Ci::Build do let(:container_registry_enabled) { false } let(:predefined_variables) do [ + { key: 'CI_JOB_ID', value: build.id.to_s, public: true }, + { key: 'CI_JOB_TOKEN', value: build.token, public: false }, + { key: 'CI_BUILD_ID', value: build.id.to_s, public: true }, + { key: 'CI_BUILD_TOKEN', value: build.token, public: false }, + { 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', value: 'true', public: true }, { key: 'GITLAB_CI', value: 'true', public: true }, { key: 'GITLAB_FEATURES', value: project.namespace.features.join(','), public: true }, { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true }, - { key: 'CI_JOB_ID', value: build.id.to_s, public: true }, { key: 'CI_JOB_NAME', value: 'test', public: true }, { key: 'CI_JOB_STAGE', value: 'test', public: true }, - { key: 'CI_JOB_TOKEN', value: build.token, public: false }, { key: 'CI_COMMIT_SHA', value: build.sha, public: true }, { key: 'CI_COMMIT_REF_NAME', value: build.ref, public: true }, { key: 'CI_COMMIT_REF_SLUG', value: build.ref_slug, 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_BUILD_ID', value: build.id.to_s, public: true }, - { key: 'CI_BUILD_TOKEN', value: build.token, public: false }, { key: 'CI_BUILD_REF', value: build.sha, public: true }, { key: 'CI_BUILD_BEFORE_SHA', value: build.before_sha, public: true }, { key: 'CI_BUILD_REF_NAME', value: build.ref, public: true }, @@ -1951,6 +1945,7 @@ describe Ci::Build do before do allow(build).to receive(:predefined_variables) { [build_pre_var] } allow(build).to receive(:yaml_variables) { [build_yaml_var] } + allow(build).to receive(:persisted_variables) { [] } allow_any_instance_of(Project) .to receive(:predefined_variables) { [project_pre_var] } @@ -1999,6 +1994,106 @@ describe Ci::Build do end end end + + context 'when build has not been persisted yet' do + let(:build) do + described_class.new( + name: 'rspec', + stage: 'test', + ref: 'feature', + project: project, + pipeline: pipeline + ) + end + + it 'returns static predefined variables' do + expect(build.variables.size).to be >= 28 + expect(build.variables) + .to include(key: 'CI_COMMIT_REF_NAME', value: 'feature', public: true) + expect(build).not_to be_persisted + end + end + end + + describe '#scoped_variables' do + context 'when build has not been persisted yet' do + let(:build) do + described_class.new( + name: 'rspec', + stage: 'test', + ref: 'feature', + project: project, + pipeline: pipeline + ) + end + + it 'does not persist the build' do + expect(build).to be_valid + expect(build).not_to be_persisted + + build.scoped_variables + + expect(build).not_to be_persisted + end + + it 'returns static predefined variables' do + keys = %w[CI_JOB_NAME + CI_COMMIT_SHA + CI_COMMIT_REF_NAME + CI_COMMIT_REF_SLUG + CI_JOB_STAGE] + + variables = build.scoped_variables + + variables.map { |env| env[:key] }.tap do |names| + expect(names).to include(*keys) + end + + expect(variables) + .to include(key: 'CI_COMMIT_REF_NAME', value: 'feature', public: true) + end + + it 'does not return prohibited variables' do + keys = %w[CI_JOB_ID + CI_JOB_TOKEN + CI_BUILD_ID + CI_BUILD_TOKEN + CI_REGISTRY_USER + CI_REGISTRY_PASSWORD + CI_REPOSITORY_URL + CI_ENVIRONMENT_URL] + + build.scoped_variables.map { |env| env[:key] }.tap do |names| + expect(names).not_to include(*keys) + end + end + end + end + + describe '#scoped_variables_hash' do + context 'when overriding secret variables' do + before do + project.variables.create!(key: 'MY_VAR', value: 'my value 1') + pipeline.variables.create!(key: 'MY_VAR', value: 'my value 2') + end + + it 'returns a regular hash created using valid ordering' do + expect(build.scoped_variables_hash).to include('MY_VAR': 'my value 2') + expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1') + end + end + + context 'when overriding user-provided variables' do + before do + pipeline.variables.build(key: 'MY_VAR', value: 'pipeline value') + build.yaml_variables = [{ key: 'MY_VAR', value: 'myvar', public: true }] + end + + it 'returns a hash including variable with higher precedence' do + expect(build.scoped_variables_hash).to include('MY_VAR': 'pipeline value') + expect(build.scoped_variables_hash).not_to include('MY_VAR': 'myvar') + end + end end describe 'state transition: any => [:pending]' do @@ -2011,6 +2106,70 @@ describe Ci::Build do end end + describe 'state transition: pending: :running' do + let(:runner) { create(:ci_runner) } + let(:job) { create(:ci_build, :pending, runner: runner) } + + before do + job.project.update_attribute(:build_timeout, 1800) + end + + def run_job_without_exception + job.run! + rescue StateMachines::InvalidTransition + end + + shared_examples 'saves data on transition' do + it 'saves timeout' do + expect { job.run! }.to change { job.reload.ensure_metadata.timeout }.from(nil).to(expected_timeout) + end + + it 'saves timeout_source' do + expect { job.run! }.to change { job.reload.ensure_metadata.timeout_source }.from('unknown_timeout_source').to(expected_timeout_source) + end + + context 'when Ci::BuildMetadata#update_timeout_state fails update' do + before do + allow_any_instance_of(Ci::BuildMetadata).to receive(:update_timeout_state).and_return(false) + end + + it "doesn't save timeout" do + expect { run_job_without_exception }.not_to change { job.reload.ensure_metadata.timeout_source } + end + + it "doesn't save timeout_source" do + expect { run_job_without_exception }.not_to change { job.reload.ensure_metadata.timeout_source } + end + + it 'raises an exception' do + expect { job.run! }.to raise_error(StateMachines::InvalidTransition) + end + end + end + + context 'when runner timeout overrides project timeout' do + let(:expected_timeout) { 900 } + let(:expected_timeout_source) { 'runner_timeout_source' } + + before do + runner.update_attribute(:maximum_timeout, 900) + end + + it_behaves_like 'saves data on transition' + end + + context "when runner timeout doesn't override project timeout" do + let(:expected_timeout) { 1800 } + let(:expected_timeout_source) { 'project_timeout_source' } + + before do + runner.update_attribute(:maximum_timeout, 3600) + end + + it_behaves_like 'saves data on transition' + end + end + describe 'state transition: any => [:running]' do shared_examples 'validation is active' do context 'when depended job has not been completed yet' do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 92f00cfbc19..dd94515b0a4 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -346,6 +346,20 @@ describe Ci::Pipeline, :mailer do end end end + + context 'when variables policy is specified' do + let(:config) do + { unit: { script: 'minitest', only: { variables: ['$CI_PIPELINE_SOURCE'] } }, + feature: { script: 'spinach', only: { variables: ['$UNDEFINED'] } } } + end + + it 'returns stage seeds only when variables expression is truthy' do + seeds = pipeline.stage_seeds + + expect(seeds.size).to eq 1 + expect(seeds.dig(0, 0, :name)).to eq 'unit' + end + end end describe '#seeds_size' do diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb index ba7bad617b4..0eb1e3876e2 100644 --- a/spec/models/clusters/applications/helm_spec.rb +++ b/spec/models/clusters/applications/helm_spec.rb @@ -3,6 +3,18 @@ require 'rails_helper' describe Clusters::Applications::Helm do include_examples 'cluster application core specs', :clusters_applications_helm + describe '.installed' do + subject { described_class.installed } + + let!(:cluster) { create(:clusters_applications_helm, :installed) } + + before do + create(:clusters_applications_helm, :errored) + end + + it { is_expected.to contain_exactly(cluster) } + end + describe '#install_command' do let(:helm) { create(:clusters_applications_helm) } diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index 03f5b88a525..a47a07d908d 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -11,6 +11,18 @@ describe Clusters::Applications::Ingress do allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async) end + describe '.installed' do + subject { described_class.installed } + + let!(:cluster) { create(:clusters_applications_ingress, :installed) } + + before do + create(:clusters_applications_ingress, :errored) + end + + it { is_expected.to contain_exactly(cluster) } + end + describe '#make_installed!' do before do application.make_installed! diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb index 2905b58066b..aeca6ee903a 100644 --- a/spec/models/clusters/applications/prometheus_spec.rb +++ b/spec/models/clusters/applications/prometheus_spec.rb @@ -4,6 +4,18 @@ describe Clusters::Applications::Prometheus do include_examples 'cluster application core specs', :clusters_applications_prometheus include_examples 'cluster application status specs', :cluster_application_prometheus + describe '.installed' do + subject { described_class.installed } + + let!(:cluster) { create(:clusters_applications_prometheus, :installed) } + + before do + create(:clusters_applications_prometheus, :errored) + end + + it { is_expected.to contain_exactly(cluster) } + end + describe 'transition to installed' do let(:project) { create(:project) } let(:cluster) { create(:cluster, projects: [project]) } diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index a574779e39d..64d995a73c1 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -8,6 +8,18 @@ describe Clusters::Applications::Runner do it { is_expected.to belong_to(:runner) } + describe '.installed' do + subject { described_class.installed } + + let!(:cluster) { create(:clusters_applications_runner, :installed) } + + before do + create(:clusters_applications_runner, :errored) + end + + it { is_expected.to contain_exactly(cluster) } + end + describe '#install_command' do let(:kubeclient) { double('kubernetes client') } let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) } diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 8f12a0e3085..b942554d67b 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -39,6 +39,42 @@ describe Clusters::Cluster do it { is_expected.to contain_exactly(cluster) } end + describe '.user_provided' do + subject { described_class.user_provided } + + let!(:cluster) { create(:cluster, :provided_by_user) } + + before do + create(:cluster, :provided_by_gcp) + end + + it { is_expected.to contain_exactly(cluster) } + end + + describe '.gcp_provided' do + subject { described_class.gcp_provided } + + let!(:cluster) { create(:cluster, :provided_by_gcp) } + + before do + create(:cluster, :provided_by_user) + end + + it { is_expected.to contain_exactly(cluster) } + end + + describe '.gcp_installed' do + subject { described_class.gcp_installed } + + let!(:cluster) { create(:cluster, :provided_by_gcp) } + + before do + create(:cluster, :providing_by_gcp) + end + + it { is_expected.to contain_exactly(cluster) } + end + describe 'validation' do subject { cluster.valid? } diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index b7ed8be69fc..c536dab2681 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -368,9 +368,7 @@ describe CommitStatus do 'rspec:windows 0 : / 1' => 'rspec:windows', 'rspec:windows 0 : / 1 name' => 'rspec:windows name', '0 1 name ruby' => 'name ruby', - '0 :/ 1 name ruby' => 'name ruby', - 'golang test 1.8' => 'golang test', - '1.9 golang test' => 'golang test' + '0 :/ 1 name ruby' => 'name ruby' } tests.each do |name, group_name| diff --git a/spec/models/concerns/chronic_duration_attribute_spec.rb b/spec/models/concerns/chronic_duration_attribute_spec.rb new file mode 100644 index 00000000000..27c86e60e60 --- /dev/null +++ b/spec/models/concerns/chronic_duration_attribute_spec.rb @@ -0,0 +1,115 @@ +require 'spec_helper' + +shared_examples 'ChronicDurationAttribute reader' do + it 'contains dynamically created reader method' do + expect(subject.class).to be_public_method_defined(virtual_field) + end + + it 'outputs chronic duration formatted value' do + subject.send("#{source_field}=", 120) + + expect(subject.send(virtual_field)).to eq('2m') + end + + context 'when value is set to nil' do + it 'outputs nil' do + subject.send("#{source_field}=", nil) + + expect(subject.send(virtual_field)).to be_nil + end + end +end + +shared_examples 'ChronicDurationAttribute writer' do + it 'contains dynamically created writer method' do + expect(subject.class).to be_public_method_defined("#{virtual_field}=") + end + + before do + subject.send("#{virtual_field}=", '10m') + end + + it 'parses chronic duration input' do + expect(subject.send(source_field)).to eq(600) + end + + it 'passes validation' do + expect(subject.valid?).to be_truthy + end + + context 'when negative input is used' do + before do + subject.send("#{source_field}=", 3600) + end + + it "doesn't raise exception" do + expect { subject.send("#{virtual_field}=", '-10m') }.not_to raise_error(ChronicDuration::DurationParseError) + end + + it "doesn't change value" do + expect { subject.send("#{virtual_field}=", '-10m') }.not_to change { subject.send(source_field) } + end + + it "doesn't pass validation" do + subject.send("#{virtual_field}=", '-10m') + + expect(subject.valid?).to be_falsey + expect(subject.errors&.messages).to include(virtual_field => ['is not a correct duration']) + end + end + + context 'when empty input is used' do + before do + subject.send("#{virtual_field}=", '') + end + + it 'writes nil' do + expect(subject.send(source_field)).to be_nil + end + + it 'passes validation' do + expect(subject.valid?).to be_truthy + end + end + + context 'when nil input is used' do + before do + subject.send("#{virtual_field}=", nil) + end + + it 'writes nil' do + expect(subject.send(source_field)).to be_nil + end + + it 'passes validation' do + expect(subject.valid?).to be_truthy + end + + it "doesn't raise exception" do + expect { subject.send("#{virtual_field}=", nil) }.not_to raise_error(NoMethodError) + end + end +end + +describe 'ChronicDurationAttribute' do + let(:source_field) {:maximum_timeout} + let(:virtual_field) {:maximum_timeout_human_readable} + + subject { Ci::Runner.new } + + it_behaves_like 'ChronicDurationAttribute reader' + it_behaves_like 'ChronicDurationAttribute writer' +end + +describe 'ChronicDurationAttribute - reader' do + let(:source_field) {:timeout} + let(:virtual_field) {:timeout_human_readable} + + subject {Ci::BuildMetadata.new} + + it "doesn't contain dynamically created writer method" do + expect(subject.class).not_to be_public_method_defined("#{virtual_field}=") + end + + it_behaves_like 'ChronicDurationAttribute reader' +end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index f8874d14e3f..05693f067e1 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -176,7 +176,7 @@ describe Issuable do end end - describe "#sort" do + describe "#sort_by_attribute" do let(:project) { create(:project) } context "by milestone due date" do @@ -193,12 +193,12 @@ describe Issuable do let!(:issue3) { create(:issue, project: project) } it "sorts desc" do - issues = project.issues.sort('milestone_due_desc') + issues = project.issues.sort_by_attribute('milestone_due_desc') expect(issues).to match_array([issue2, issue1, issue, issue3]) end it "sorts asc" do - issues = project.issues.sort('milestone_due_asc') + issues = project.issues.sort_by_attribute('milestone_due_asc') expect(issues).to match_array([issue1, issue2, issue, issue3]) end end @@ -210,7 +210,7 @@ describe Issuable do it 'has no duplicates across pages' do sorted_issue_ids = 1.upto(10).map do |i| - project.issues.sort('milestone_due_desc').page(i).per(1).first.id + project.issues.sort_by_attribute('milestone_due_desc').page(i).per(1).first.id end expect(sorted_issue_ids).to eq(sorted_issue_ids.uniq) diff --git a/spec/models/deploy_key_spec.rb b/spec/models/deploy_key_spec.rb index 3d7283e2164..41440c6d288 100644 --- a/spec/models/deploy_key_spec.rb +++ b/spec/models/deploy_key_spec.rb @@ -17,4 +17,25 @@ describe DeployKey, :mailer do should_not_email(user) end end + + describe '#user' do + let(:deploy_key) { create(:deploy_key) } + let(:user) { create(:user) } + + context 'when user is set' do + before do + deploy_key.user = user + end + + it 'returns the user' do + expect(deploy_key.user).to be(user) + end + end + + context 'when user is not set' do + it 'returns the ghost user' do + expect(deploy_key.user).to eq(User.ghost) + end + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 1a7a6e035ea..0e560be9eaa 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -224,14 +224,14 @@ describe Project do project2 = build(:project, import_url: 'http://localhost:9000/t.git') expect(project2).to be_invalid - expect(project2.errors[:import_url]).to include('imports are not allowed from that URL') + expect(project2.errors[:import_url].first).to include('Requests to localhost are not allowed') end it "does not allow blocked import_url port" do project2 = build(:project, import_url: 'http://github.com:25/t.git') expect(project2).to be_invalid - expect(project2.errors[:import_url]).to include('imports are not allowed from that URL') + expect(project2.errors[:import_url].first).to include('Only allowed ports are 22, 80, 443') end describe 'project pending deletion' do @@ -1265,6 +1265,34 @@ describe Project do end end + describe '#pages_group_url' do + let(:group) { create :group, name: group_name } + let(:project) { create :project, namespace: group, name: project_name } + let(:domain) { 'Example.com' } + let(:port) { 1234 } + + subject { project.pages_group_url } + + before do + allow(Settings.pages).to receive(:host).and_return(domain) + allow(Gitlab.config.pages).to receive(:url).and_return("http://example.com:#{port}") + end + + context 'group page' do + let(:group_name) { 'Group' } + let(:project_name) { 'group.example.com' } + + it { is_expected.to eq("http://group.example.com:#{port}") } + end + + context 'project page' do + let(:group_name) { 'Group' } + let(:project_name) { 'Project' } + + it { is_expected.to eq("http://group.example.com:#{port}") } + end + end + describe '.search' do let(:project) { create(:project, description: 'kitten mittens') } @@ -1617,7 +1645,7 @@ describe Project do before do allow_any_instance_of(Gitlab::Shell).to receive(:import_repository) - .with(project.repository_storage_path, project.disk_path, project.import_url) + .with(project.repository_storage, project.disk_path, project.import_url) .and_return(true) expect_any_instance_of(Repository).to receive(:after_import) @@ -1770,10 +1798,7 @@ describe Project do let(:project) { forked_project_link.forked_to_project } it 'schedules a RepositoryForkWorker job' do - expect(RepositoryForkWorker).to receive(:perform_async).with( - project.id, - forked_from_project.repository_storage_path, - forked_from_project.disk_path).and_return(import_jid) + expect(RepositoryForkWorker).to receive(:perform_async).with(project.id).and_return(import_jid) expect(project.add_import_job).to eq(import_jid) end @@ -2532,7 +2557,7 @@ describe Project do end end - describe '#remove_exports' do + describe '#remove_export' do let(:legacy_project) { create(:project, :legacy_storage, :with_export) } let(:project) { create(:project, :with_export) } @@ -2580,6 +2605,23 @@ describe Project do end end + describe '#remove_exported_project_file' do + let(:project) { create(:project, :with_export) } + + it 'removes the exported project file' do + exported_file = project.export_project_path + + expect(File.exist?(exported_file)).to be_truthy + + allow(FileUtils).to receive(:rm_f).and_call_original + expect(FileUtils).to receive(:rm_f).with(exported_file).and_call_original + + project.remove_exported_project_file + + expect(File.exist?(exported_file)).to be_falsy + end + end + describe '#forks_count' do it 'returns the number of forks' do project = build(:project) diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 79f25dc4360..83ed3b203e6 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -58,6 +58,21 @@ describe Service do end describe "Template" do + describe '.build_from_template' do + context 'when template is invalid' do + it 'sets service template to inactive when template is invalid' do + project = create(:project) + template = JiraService.new(template: true, active: true) + template.save(validate: false) + + service = described_class.build_from_template(project.id, template) + + expect(service).to be_valid + expect(service.active).to be false + end + end + end + describe "for pushover service" do let!(:service_template) do PushoverService.create( diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index bbfdda23a31..4027c420e47 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -25,7 +25,7 @@ describe User do it { is_expected.to have_many(:group_members) } it { is_expected.to have_many(:groups) } it { is_expected.to have_many(:keys).dependent(:destroy) } - it { is_expected.to have_many(:deploy_keys).dependent(:destroy) } + it { is_expected.to have_many(:deploy_keys).dependent(:nullify) } it { is_expected.to have_many(:events).dependent(:destroy) } it { is_expected.to have_many(:issues).dependent(:destroy) } it { is_expected.to have_many(:notes).dependent(:destroy) } @@ -1451,7 +1451,7 @@ describe User do end end - describe '#sort' do + describe '#sort_by_attribute' do before do described_class.delete_all @user = create :user, created_at: Date.today, current_sign_in_at: Date.today, name: 'Alpha' @@ -1460,7 +1460,7 @@ describe User do end context 'when sort by recent_sign_in' do - let(:users) { described_class.sort('recent_sign_in') } + let(:users) { described_class.sort_by_attribute('recent_sign_in') } it 'sorts users by recent sign-in time' do expect(users.first).to eq(@user) @@ -1473,7 +1473,7 @@ describe User do end context 'when sort by oldest_sign_in' do - let(:users) { described_class.sort('oldest_sign_in') } + let(:users) { described_class.sort_by_attribute('oldest_sign_in') } it 'sorts users by the oldest sign-in time' do expect(users.first).to eq(@user1) @@ -1486,15 +1486,15 @@ describe User do end it 'sorts users in descending order by their creation time' do - expect(described_class.sort('created_desc').first).to eq(@user) + expect(described_class.sort_by_attribute('created_desc').first).to eq(@user) end it 'sorts users in ascending order by their creation time' do - expect(described_class.sort('created_asc').first).to eq(@user2) + expect(described_class.sort_by_attribute('created_asc').first).to eq(@user2) end it 'sorts users by id in descending order when nil is passed' do - expect(described_class.sort(nil).first).to eq(@user2) + expect(described_class.sort_by_attribute(nil).first).to eq(@user2) end end diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb index 0772b3f2e64..ae9c0e9c304 100644 --- a/spec/requests/api/deploy_keys_spec.rb +++ b/spec/requests/api/deploy_keys_spec.rb @@ -91,6 +91,10 @@ describe API::DeployKeys do expect do post api("/projects/#{project.id}/deploy_keys", admin), key_attrs end.to change { project.deploy_keys.count }.by(1) + + new_key = project.deploy_keys.last + expect(new_key.key).to eq(key_attrs[:key]) + expect(new_key.user).to eq(admin) end it 'returns an existing ssh key when attempting to add a duplicate' do diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb index 267058d98ee..c5354c2d639 100644 --- a/spec/requests/api/features_spec.rb +++ b/spec/requests/api/features_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' describe API::Features do - let(:user) { create(:user) } - let(:admin) { create(:admin) } + set(:user) { create(:user) } + set(:admin) { create(:admin) } before do Flipper.unregister_groups @@ -249,4 +249,43 @@ describe API::Features do end end end + + describe 'DELETE /feature/:name' do + let(:feature_name) { 'my_feature' } + + context 'when the user has no access' do + it 'returns a 401 for anonymous users' do + delete api("/features/#{feature_name}") + + expect(response).to have_gitlab_http_status(401) + end + + it 'returns a 403 for users' do + delete api("/features/#{feature_name}", user) + + expect(response).to have_gitlab_http_status(403) + end + end + + context 'when the user has access' do + it 'returns 204 when the value is not set' do + delete api("/features/#{feature_name}", admin) + + expect(response).to have_gitlab_http_status(204) + end + + context 'when the gate value was set' do + before do + Feature.get(feature_name).enable + end + + it 'deletes an enabled feature' do + delete api("/features/#{feature_name}", admin) + + expect(response).to have_gitlab_http_status(204) + expect(Feature.get(feature_name)).not_to be_enabled + end + end + end + end end diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 3cb90a1b8ef..db8c5f963d6 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -251,44 +251,23 @@ describe API::Internal do end context 'with env passed as a JSON' do - context 'when relative path envs are not set' do - it 'sets env in RequestStore' do - expect(Gitlab::Git::Env).to receive(:set).with({ - 'GIT_OBJECT_DIRECTORY' => 'foo', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'bar' - }) - - push(key, project.wiki, env: { - GIT_OBJECT_DIRECTORY: 'foo', - GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar' - }.to_json) + let(:gl_repository) { project.gl_repository(is_wiki: true) } - expect(response).to have_gitlab_http_status(200) - end - end + it 'sets env in RequestStore' do + obj_dir_relative = './objects' + alt_obj_dirs_relative = ['./alt-objects-1', './alt-objects-2'] - context 'when relative path envs are set' do - it 'sets env in RequestStore' do - obj_dir_relative = './objects' - alt_obj_dirs_relative = ['./alt-objects-1', './alt-objects-2'] - repo_path = project.wiki.repository.path_to_repo - - expect(Gitlab::Git::Env).to receive(:set).with({ - 'GIT_OBJECT_DIRECTORY' => File.join(repo_path, obj_dir_relative), - 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => alt_obj_dirs_relative.map { |d| File.join(repo_path, d) }, - 'GIT_OBJECT_DIRECTORY_RELATIVE' => obj_dir_relative, - 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => alt_obj_dirs_relative - }) - - push(key, project.wiki, env: { - GIT_OBJECT_DIRECTORY: 'foo', - GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar', - GIT_OBJECT_DIRECTORY_RELATIVE: obj_dir_relative, - GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE: alt_obj_dirs_relative - }.to_json) + expect(Gitlab::Git::HookEnv).to receive(:set).with(gl_repository, { + 'GIT_OBJECT_DIRECTORY_RELATIVE' => obj_dir_relative, + 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => alt_obj_dirs_relative + }) - expect(response).to have_gitlab_http_status(200) - end + push(key, project.wiki, env: { + GIT_OBJECT_DIRECTORY_RELATIVE: obj_dir_relative, + GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE: alt_obj_dirs_relative + }.to_json) + + expect(response).to have_gitlab_http_status(200) end end diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb index 12583109b59..3834d27d0a9 100644 --- a/spec/requests/api/project_export_spec.rb +++ b/spec/requests/api/project_export_spec.rb @@ -5,6 +5,7 @@ describe API::ProjectExport do set(:project_none) { create(:project) } set(:project_started) { create(:project) } set(:project_finished) { create(:project) } + set(:project_after_export) { create(:project) } set(:user) { create(:user) } set(:admin) { create(:admin) } @@ -12,11 +13,13 @@ describe API::ProjectExport do let(:path_none) { "/projects/#{project_none.id}/export" } let(:path_started) { "/projects/#{project_started.id}/export" } let(:path_finished) { "/projects/#{project_finished.id}/export" } + let(:path_after_export) { "/projects/#{project_after_export.id}/export" } let(:download_path) { "/projects/#{project.id}/export/download" } let(:download_path_none) { "/projects/#{project_none.id}/export/download" } let(:download_path_started) { "/projects/#{project_started.id}/export/download" } let(:download_path_finished) { "/projects/#{project_finished.id}/export/download" } + let(:download_path_export_action) { "/projects/#{project_after_export.id}/export/download" } let(:export_path) { "#{Dir.tmpdir}/project_export_spec" } @@ -29,6 +32,11 @@ describe API::ProjectExport do # simulate exported FileUtils.mkdir_p project_finished.export_path FileUtils.touch File.join(project_finished.export_path, '_export.tar.gz') + + # simulate in after export action + FileUtils.mkdir_p project_after_export.export_path + FileUtils.touch File.join(project_after_export.export_path, '_export.tar.gz') + FileUtils.touch Gitlab::ImportExport::AfterExportStrategies::BaseAfterExportStrategy.lock_file_path(project_after_export) end after do @@ -73,6 +81,14 @@ describe API::ProjectExport do expect(json_response['export_status']).to eq('started') end + it 'is after_export' do + get api(path_after_export, user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/project/export_status') + expect(json_response['export_status']).to eq('after_export_action') + end + it 'is finished' do get api(path_finished, user) @@ -99,6 +115,7 @@ describe API::ProjectExport do project_none.add_master(user) project_started.add_master(user) project_finished.add_master(user) + project_after_export.add_master(user) end it_behaves_like 'get project export status ok' @@ -163,6 +180,36 @@ describe API::ProjectExport do end end + shared_examples_for 'get project export upload after action' do + context 'and is uploading' do + it 'downloads' do + get api(download_path_export_action, user) + + expect(response).to have_gitlab_http_status(200) + end + end + + context 'when upload complete' do + before do + FileUtils.rm_rf(project_after_export.export_path) + end + + it_behaves_like '404 response' do + let(:request) { get api(download_path_export_action, user) } + end + end + end + + shared_examples_for 'get project download by strategy' do + context 'when upload strategy set' do + it_behaves_like 'get project export upload after action' + end + + context 'when download strategy set' do + it_behaves_like 'get project export download' + end + end + it_behaves_like 'when project export is disabled' do let(:request) { get api(download_path, admin) } end @@ -171,7 +218,7 @@ describe API::ProjectExport do context 'when user is an admin' do let(:user) { admin } - it_behaves_like 'get project export download' + it_behaves_like 'get project download by strategy' end context 'when user is a master' do @@ -180,9 +227,10 @@ describe API::ProjectExport do project_none.add_master(user) project_started.add_master(user) project_finished.add_master(user) + project_after_export.add_master(user) end - it_behaves_like 'get project export download' + it_behaves_like 'get project download by strategy' end context 'when user is a developer' do @@ -229,10 +277,30 @@ describe API::ProjectExport do end shared_examples_for 'post project export start' do - it 'starts' do - post api(path, user) + context 'with upload strategy' do + context 'when params invalid' do + it_behaves_like '400 response' do + let(:request) { post(api(path, user), 'upload[url]' => 'whatever') } + end + end + + it 'starts' do + allow_any_instance_of(Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy).to receive(:send_file) + + post(api(path, user), 'upload[url]' => 'http://gitlab.com') - expect(response).to have_gitlab_http_status(202) + expect(response).to have_gitlab_http_status(202) + end + end + + context 'with download strategy' do + it 'starts' do + expect_any_instance_of(Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy).not_to receive(:send_file) + + post api(path, user) + + expect(response).to have_gitlab_http_status(202) + end end end @@ -253,6 +321,7 @@ describe API::ProjectExport do project_none.add_master(user) project_started.add_master(user) project_finished.add_master(user) + project_after_export.add_master(user) end it_behaves_like 'post project export start' diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index d73a42f48ad..2ec29a79e93 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -452,7 +452,8 @@ describe API::Projects do only_allow_merge_if_pipeline_succeeds: false, request_access_enabled: true, only_allow_merge_if_all_discussions_are_resolved: false, - ci_config_path: 'a/custom/path' + ci_config_path: 'a/custom/path', + merge_method: 'ff' }) post api('/projects', user), project @@ -569,6 +570,22 @@ describe API::Projects do expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to be_truthy end + it 'sets the merge method of a project to rebase merge' do + project = attributes_for(:project, merge_method: 'rebase_merge') + + post api('/projects', user), project + + expect(json_response['merge_method']).to eq('rebase_merge') + end + + it 'rejects invalid values for merge_method' do + project = attributes_for(:project, merge_method: 'totally_not_valid_method') + + post api('/projects', user), project + + expect(response).to have_gitlab_http_status(400) + end + it 'ignores import_url when it is nil' do project = attributes_for(:project, import_url: nil) @@ -823,6 +840,7 @@ describe API::Projects do expect(json_response['shared_with_groups'][0]['group_access_level']).to eq(link.group_access) expect(json_response['only_allow_merge_if_pipeline_succeeds']).to eq(project.only_allow_merge_if_pipeline_succeeds) expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to eq(project.only_allow_merge_if_all_discussions_are_resolved) + expect(json_response['merge_method']).to eq(project.merge_method.to_s) end it 'returns a project by path name' do @@ -1474,6 +1492,26 @@ describe API::Projects do expect(json_response[k.to_s]).to eq(v) end end + + it 'updates merge_method' do + project_param = { merge_method: 'ff' } + + put api("/projects/#{project3.id}", user), project_param + + expect(response).to have_gitlab_http_status(200) + + project_param.each_pair do |k, v| + expect(json_response[k.to_s]).to eq(v) + end + end + + it 'rejects to update merge_method when merge_method is invalid' do + project_param = { merge_method: 'invalid' } + + put api("/projects/#{project3.id}", user), project_param + + expect(response).to have_gitlab_http_status(400) + end end context 'when authenticated as project master' do @@ -1491,6 +1529,7 @@ describe API::Projects do wiki_enabled: true, snippets_enabled: true, merge_requests_enabled: true, + merge_method: 'ff', description: 'new description' } put api("/projects/#{project3.id}", user4), project_param diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index f3dd121faa9..4f3420cc0ad 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -109,6 +109,26 @@ describe API::Runner do end end + context 'when maximum job timeout is specified' do + it 'creates runner' do + post api('/runners'), token: registration_token, + maximum_timeout: 9000 + + expect(response).to have_gitlab_http_status 201 + expect(Ci::Runner.first.maximum_timeout).to eq(9000) + end + + context 'when maximum job timeout is empty' do + it 'creates runner' do + post api('/runners'), token: registration_token, + maximum_timeout: '' + + expect(response).to have_gitlab_http_status 201 + expect(Ci::Runner.first.maximum_timeout).to be_nil + end + end + end + %w(name version revision platform architecture).each do |param| context "when info parameter '#{param}' info is present" do let(:value) { "#{param}_value" } @@ -340,12 +360,12 @@ describe API::Runner do let(:expected_steps) do [{ 'name' => 'script', 'script' => %w(ls date), - 'timeout' => job.timeout, + 'timeout' => job.metadata_timeout, 'when' => 'on_success', 'allow_failure' => false }, { 'name' => 'after_script', 'script' => %w(ls date), - 'timeout' => job.timeout, + 'timeout' => job.metadata_timeout, 'when' => 'always', 'allow_failure' => true }] end @@ -648,6 +668,41 @@ describe API::Runner do end end end + + describe 'timeout support' do + context 'when project specifies job timeout' do + let(:project) { create(:project, shared_runners_enabled: false, build_timeout: 1234) } + + it 'contains info about timeout taken from project' do + request_job + + expect(response).to have_gitlab_http_status(201) + expect(json_response['runner_info']).to include({ 'timeout' => 1234 }) + end + + context 'when runner specifies lower timeout' do + let(:runner) { create(:ci_runner, maximum_timeout: 1000) } + + it 'contains info about timeout overridden by runner' do + request_job + + expect(response).to have_gitlab_http_status(201) + expect(json_response['runner_info']).to include({ 'timeout' => 1000 }) + end + end + + context 'when runner specifies bigger timeout' do + let(:runner) { create(:ci_runner, maximum_timeout: 2000) } + + it 'contains info about timeout not overridden by runner' do + request_job + + expect(response).to have_gitlab_http_status(201) + expect(json_response['runner_info']).to include({ 'timeout' => 1234 }) + end + end + end + end end def request_job(token = runner.token, **params) @@ -1104,11 +1159,13 @@ describe API::Runner do let!(:artifacts) { file_upload } let!(:artifacts_sha256) { Digest::SHA256.file(artifacts.path).hexdigest } let!(:metadata) { file_upload2 } + let!(:metadata_sha256) { Digest::SHA256.file(metadata.path).hexdigest } let(:stored_artifacts_file) { job.reload.artifacts_file.file } let(:stored_metadata_file) { job.reload.artifacts_metadata.file } let(:stored_artifacts_size) { job.reload.artifacts_size } let(:stored_artifacts_sha256) { job.reload.job_artifacts_archive.file_sha256 } + let(:stored_metadata_sha256) { job.reload.job_artifacts_metadata.file_sha256 } before do post(api("/jobs/#{job.id}/artifacts"), post_data, headers_with_token) @@ -1120,7 +1177,8 @@ describe API::Runner do 'file.name' => artifacts.original_filename, 'file.sha256' => artifacts_sha256, 'metadata.path' => metadata.path, - 'metadata.name' => metadata.original_filename } + 'metadata.name' => metadata.original_filename, + 'metadata.sha256' => metadata_sha256 } end it 'stores artifacts and artifacts metadata' do @@ -1129,6 +1187,7 @@ describe API::Runner do expect(stored_metadata_file.original_filename).to eq(metadata.original_filename) expect(stored_artifacts_size).to eq(72821) expect(stored_artifacts_sha256).to eq(artifacts_sha256) + expect(stored_metadata_sha256).to eq(metadata_sha256) end end diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb index ec5cad4f4fd..d30f0cf36e2 100644 --- a/spec/requests/api/runners_spec.rb +++ b/spec/requests/api/runners_spec.rb @@ -123,6 +123,7 @@ describe API::Runners do expect(response).to have_gitlab_http_status(200) expect(json_response['description']).to eq(shared_runner.description) + expect(json_response['maximum_timeout']).to be_nil end end @@ -192,7 +193,8 @@ describe API::Runners do tag_list: ['ruby2.1', 'pgsql', 'mysql'], run_untagged: 'false', locked: 'true', - access_level: 'ref_protected') + access_level: 'ref_protected', + maximum_timeout: 1234) shared_runner.reload expect(response).to have_gitlab_http_status(200) @@ -204,6 +206,7 @@ describe API::Runners do expect(shared_runner.ref_protected?).to be_truthy expect(shared_runner.ensure_runner_queue_value) .not_to eq(runner_queue_value) + expect(shared_runner.maximum_timeout).to eq(1234) end end diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb index eef860821e5..bcc3e3a2678 100644 --- a/spec/requests/projects/cycle_analytics_events_spec.rb +++ b/spec/requests/projects/cycle_analytics_events_spec.rb @@ -23,7 +23,7 @@ describe 'cycle analytics events' do it 'lists the issue events' do get project_cycle_analytics_issue_path(project, format: :json) - first_issue_iid = project.issues.sort(:created_desc).pluck(:iid).first.to_s + first_issue_iid = project.issues.sort_by_attribute(:created_desc).pluck(:iid).first.to_s expect(json_response['events']).not_to be_empty expect(json_response['events'].first['iid']).to eq(first_issue_iid) @@ -32,7 +32,7 @@ describe 'cycle analytics events' do it 'lists the plan events' do get project_cycle_analytics_plan_path(project, format: :json) - first_mr_short_sha = project.merge_requests.sort(:created_asc).first.commits.first.short_id + first_mr_short_sha = project.merge_requests.sort_by_attribute(:created_asc).first.commits.first.short_id expect(json_response['events']).not_to be_empty expect(json_response['events'].first['short_sha']).to eq(first_mr_short_sha) @@ -43,7 +43,7 @@ describe 'cycle analytics events' do expect(json_response['events']).not_to be_empty - first_mr_iid = project.merge_requests.sort(:created_desc).pluck(:iid).first.to_s + first_mr_iid = project.merge_requests.sort_by_attribute(:created_desc).pluck(:iid).first.to_s expect(json_response['events'].first['iid']).to eq(first_mr_iid) end @@ -58,7 +58,7 @@ describe 'cycle analytics events' do it 'lists the review events' do get project_cycle_analytics_review_path(project, format: :json) - first_mr_iid = project.merge_requests.sort(:created_desc).pluck(:iid).first.to_s + first_mr_iid = project.merge_requests.sort_by_attribute(:created_desc).pluck(:iid).first.to_s expect(json_response['events']).not_to be_empty expect(json_response['events'].first['iid']).to eq(first_mr_iid) @@ -74,7 +74,7 @@ describe 'cycle analytics events' do it 'lists the production events' do get project_cycle_analytics_production_path(project, format: :json) - first_issue_iid = project.issues.sort(:created_desc).pluck(:iid).first.to_s + first_issue_iid = project.issues.sort_by_attribute(:created_desc).pluck(:iid).first.to_s expect(json_response['events']).not_to be_empty expect(json_response['events'].first['iid']).to eq(first_issue_iid) diff --git a/spec/serializers/discussion_entity_spec.rb b/spec/serializers/discussion_entity_spec.rb index 7ee8e38af1c..7e19e74ca00 100644 --- a/spec/serializers/discussion_entity_spec.rb +++ b/spec/serializers/discussion_entity_spec.rb @@ -6,7 +6,7 @@ describe DiscussionEntity do let(:user) { create(:user) } let(:note) { create(:discussion_note_on_merge_request) } let(:discussion) { note.discussion } - let(:request) { double('request') } + let(:request) { double('request', note_entity: ProjectNoteEntity) } let(:controller) { double('controller') } let(:entity) { described_class.new(discussion, request: request, context: controller) } diff --git a/spec/serializers/note_entity_spec.rb b/spec/serializers/note_entity_spec.rb index 51a8587ace9..13cda781cda 100644 --- a/spec/serializers/note_entity_spec.rb +++ b/spec/serializers/note_entity_spec.rb @@ -10,53 +10,5 @@ describe NoteEntity do let(:user) { create(:user) } subject { entity.as_json } - context 'basic note' do - it 'exposes correct elements' do - expect(subject).to include(:type, :author, :human_access, :note, :note_html, :current_user, - :discussion_id, :emoji_awardable, :award_emoji, :toggle_award_path, :report_abuse_path, :path, :attachment) - end - - it 'does not expose elements for specific notes cases' do - expect(subject).not_to include(:last_edited_by, :last_edited_at, :system_note_icon_name) - end - - it 'exposes author correctly' do - expect(subject[:author]).to include(:id, :name, :username, :state, :avatar_url, :path) - end - - it 'does not expose web_url for author' do - expect(subject[:author]).not_to include(:web_url) - end - end - - context 'when note was edited' do - before do - note.update(updated_at: 1.minute.from_now, updated_by: user) - end - - it 'exposes last_edited_at and last_edited_by elements' do - expect(subject).to include(:last_edited_at, :last_edited_by) - end - end - - context 'when note is a system note' do - before do - note.update(system: true) - end - - it 'exposes system_note_icon_name element' do - expect(subject).to include(:system_note_icon_name) - end - end - - context 'when note is part of resolvable discussion' do - before do - allow(note).to receive(:part_of_discussion?).and_return(true) - allow(note).to receive(:resolvable?).and_return(true) - end - - it 'exposes paths to resolve note' do - expect(subject).to include(:resolve_path, :resolve_with_issue_path) - end - end + it_behaves_like 'note entity' end diff --git a/spec/serializers/project_note_entity_spec.rb b/spec/serializers/project_note_entity_spec.rb new file mode 100644 index 00000000000..dafd1cf603e --- /dev/null +++ b/spec/serializers/project_note_entity_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe ProjectNoteEntity do + include Gitlab::Routing + + let(:request) { double('request', current_user: user, noteable: note.noteable) } + + let(:entity) { described_class.new(note, request: request) } + let(:note) { create(:note) } + let(:user) { create(:user) } + subject { entity.as_json } + + it_behaves_like 'note entity' + + it 'exposes project-specific elements' do + expect(subject).to include(:human_access, :toggle_award_path, :path) + end + + context 'when note is part of resolvable discussion' do + before do + allow(note).to receive(:part_of_discussion?).and_return(true) + allow(note).to receive(:resolvable?).and_return(true) + end + + it 'exposes paths to resolve note' do + expect(subject).to include(:resolve_path, :resolve_with_issue_path) + end + end +end diff --git a/spec/serializers/status_entity_spec.rb b/spec/serializers/status_entity_spec.rb index 16431ed4188..70402bac2e2 100644 --- a/spec/serializers/status_entity_spec.rb +++ b/spec/serializers/status_entity_spec.rb @@ -25,5 +25,10 @@ describe StatusEntity do allow(Rails.env).to receive(:development?) { true } expect(entity.as_json[:favicon]).to match_asset_path('/assets/ci_favicons/dev/favicon_status_success.ico') end + + it 'contains a canary namespaced favicon if canary env' do + stub_env('CANARY', 'true') + expect(entity.as_json[:favicon]).to match_asset_path('/assets/ci_favicons/canary/favicon_status_success.ico') + end end end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index b86a3d72bb4..8de0bdf92e2 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -29,7 +29,8 @@ describe Ci::RetryBuildService do commit_id deployments erased_by_id last_deployment project_id runner_id tag_taggings taggings tags trigger_request_id user_id auto_canceled_by_id retried failure_reason - artifacts_file_store artifacts_metadata_store].freeze + artifacts_file_store artifacts_metadata_store + metadata].freeze shared_examples 'build duplication' do let(:another_pipeline) { create(:ci_empty_pipeline, project: project) } diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 47c1ebbeb81..7ae49c06896 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -67,6 +67,10 @@ describe Issues::CloseService do expect(issue).to be_closed end + it 'records closed user' do + expect(issue.closed_by_id).to be(user.id) + end + it 'sends email to user2 about assign of new issue' do email = ActionMailer::Base.deliveries.last expect(email.to.first).to eq(user2.email) diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 4413c6ef83e..2cacb97a293 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -70,6 +70,16 @@ describe Projects::CreateService, '#execute' do opts[:default_branch] = 'master' expect(create_project(user, opts)).to eq(nil) end + + it 'sets invalid service as inactive' do + create(:service, type: 'JiraService', project: nil, template: true, active: true) + + project = create_project(user, opts) + service = project.services.first + + expect(project).to be_persisted + expect(service.active).to be false + end end context 'wiki_enabled creates repository directory' do @@ -232,14 +242,15 @@ describe Projects::CreateService, '#execute' do end context 'when a bad service template is created' do - it 'reports an error in the imported project' do + it 'sets service to be inactive' 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) + service = project.services.first - 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 + expect(project).to be_persisted + expect(service.active).to be false end end diff --git a/spec/services/projects/import_export/export_service_spec.rb b/spec/services/projects/import_export/export_service_spec.rb new file mode 100644 index 00000000000..51491c7d529 --- /dev/null +++ b/spec/services/projects/import_export/export_service_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +describe Projects::ImportExport::ExportService do + describe '#execute' do + let!(:user) { create(:user) } + let(:project) { create(:project) } + let(:shared) { project.import_export_shared } + let(:service) { described_class.new(project, user) } + let!(:after_export_strategy) { Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy.new } + + context 'when all saver services succeed' do + before do + allow(service).to receive(:save_services).and_return(true) + end + + it 'saves the project in the file system' do + expect(Gitlab::ImportExport::Saver).to receive(:save).with(project: project, shared: shared) + + service.execute + end + + it 'calls the after export strategy' do + expect(after_export_strategy).to receive(:execute) + + service.execute(after_export_strategy) + end + + context 'when after export strategy fails' do + before do + allow(after_export_strategy).to receive(:execute).and_return(false) + end + + after do + service.execute(after_export_strategy) + end + + it 'removes the remaining exported data' do + allow(shared).to receive(:export_path).and_return('whatever') + allow(FileUtils).to receive(:rm_rf) + + expect(FileUtils).to receive(:rm_rf).with(shared.export_path) + end + + it 'notifies the user' do + expect_any_instance_of(NotificationService).to receive(:project_not_exported) + end + + it 'notifies logger' do + allow(Rails.logger).to receive(:error) + + expect(Rails.logger).to receive(:error) + end + end + end + + context 'when saver services fail' do + before do + allow(service).to receive(:save_services).and_return(false) + end + + after do + expect { service.execute }.to raise_error(Gitlab::ImportExport::Error) + end + + it 'removes the remaining exported data' do + allow(shared).to receive(:export_path).and_return('whatever') + allow(FileUtils).to receive(:rm_rf) + + expect(FileUtils).to receive(:rm_rf).with(shared.export_path) + end + + it 'notifies the user' do + expect_any_instance_of(NotificationService).to receive(:project_not_exported) + end + + it 'notifies logger' do + expect(Rails.logger).to receive(:error) + end + + it 'the after export strategy is not called' do + expect(service).not_to receive(:execute_after_export_action) + end + end + end +end diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb index bf7facaec99..30c89ebd821 100644 --- a/spec/services/projects/import_service_spec.rb +++ b/spec/services/projects/import_service_spec.rb @@ -156,7 +156,7 @@ describe Projects::ImportService do result = described_class.new(project, user).execute expect(result[:status]).to eq :error - expect(result[:message]).to end_with 'Blocked import URL.' + expect(result[:message]).to include('Requests to localhost are not allowed') end it 'fails with port 25' do @@ -165,7 +165,7 @@ describe Projects::ImportService do result = described_class.new(project, user).execute expect(result[:status]).to eq :error - expect(result[:message]).to end_with 'Blocked import URL.' + expect(result[:message]).to include('Only allowed ports are 22, 80, 443') end end diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index 934106627a9..dd31a677dfe 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -87,7 +87,8 @@ describe Projects::UpdatePagesService do it 'fails for empty file fails' do build.update_attributes(legacy_artifacts_file: empty_file) - expect(execute).not_to eq(:success) + expect { execute } + .to raise_error(Projects::UpdatePagesService::FailedToExtractError) end end end @@ -159,7 +160,8 @@ describe Projects::UpdatePagesService do it 'fails for empty file fails' do build.job_artifacts_archive.update_attributes(file: empty_file) - expect(execute).not_to eq(:success) + expect { execute } + .to raise_error(Projects::UpdatePagesService::FailedToExtractError) end context 'when timeout happens by DNS error' do @@ -172,7 +174,39 @@ describe Projects::UpdatePagesService do expect { execute }.to raise_error(SocketError) build.reload - expect(build.artifacts?).to eq(true) + expect(deploy_status).to be_failed + expect(build.artifacts?).to be_truthy + end + end + + context 'when failed to extract zip artifacts' do + before do + allow_any_instance_of(described_class) + .to receive(:extract_zip_archive!) + .and_raise(Projects::UpdatePagesService::FailedToExtractError) + end + + it 'raises an error' do + expect { execute } + .to raise_error(Projects::UpdatePagesService::FailedToExtractError) + + build.reload + expect(deploy_status).to be_failed + expect(build.artifacts?).to be_truthy + end + end + + context 'when missing artifacts metadata' do + before do + allow(build).to receive(:artifacts_metadata?).and_return(false) + end + + it 'does not raise an error and remove artifacts as failed job' do + execute + + build.reload + expect(deploy_status).to be_failed + expect(build.artifacts?).to be_falsey end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e8cecf361ff..beabba99cf5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -108,7 +108,8 @@ RSpec.configure do |config| allow_any_instance_of(Gitlab::Git::GitlabProjects).to receive(:fork_repository).and_wrap_original do |m, *args| m.call(*args) - shard_path, repository_relative_path = args + shard_name, repository_relative_path = args + shard_path = Gitlab.config.repositories.storages.fetch(shard_name).legacy_disk_path # We can't leave the hooks in place after a fork, as those would fail in tests # The "internal" API is not available FileUtils.rm_rf(File.join(shard_path, repository_relative_path, 'hooks')) diff --git a/spec/support/cookie_helper.rb b/spec/support/cookie_helper.rb index d72925e1838..5ff7b0b68c9 100644 --- a/spec/support/cookie_helper.rb +++ b/spec/support/cookie_helper.rb @@ -2,12 +2,25 @@ # module CookieHelper def set_cookie(name, value, options = {}) + case page.driver + when Capybara::RackTest::Driver + rack_set_cookie(name, value) + else + selenium_set_cookie(name, value, options) + end + end + + def selenium_set_cookie(name, value, options = {}) # Selenium driver will not set cookies for a given domain when the browser is at `about:blank`. # It also doesn't appear to allow overriding the cookie path. loading `/` is the most inclusive. visit options.fetch(:path, '/') unless on_a_page? page.driver.browser.manage.add_cookie(name: name, value: value, **options) end + def rack_set_cookie(name, value) + page.driver.browser.set_cookie("#{name}=#{value}") + end + def get_cookie(name) page.driver.browser.manage.cookie_named(name) end diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb index c8662d41769..80604395adf 100644 --- a/spec/support/features/discussion_comments_shared_example.rb +++ b/spec/support/features/discussion_comments_shared_example.rb @@ -81,7 +81,10 @@ shared_examples 'discussion comments' do |resource_name| # on issues page, the menu closes when clicking anywhere, on other pages it will # remain open if clicking divider or menu padding, but should not change button action - if resource_name == 'issue' + # + # if dropdown menu is not toggled (and also not present), + # it's "issue-type" dropdown + if first(menu_selector).nil? expect(find(dropdown_selector)).to have_content 'Comment' find(toggle_selector).click @@ -107,8 +110,10 @@ shared_examples 'discussion comments' do |resource_name| end it 'updates the submit button text and closes the dropdown' do + button = find(submit_selector) + # on issues page, the submit input is a <button>, on other pages it is <input> - if resource_name == 'issue' + if button.tag_name == 'button' expect(find(submit_selector)).to have_content 'Start discussion' else expect(find(submit_selector).value).to eq 'Start discussion' @@ -132,6 +137,8 @@ shared_examples 'discussion comments' do |resource_name| describe 'creating a discussion' do before do find(submit_selector).click + wait_for_requests + find(comments_selector, match: :first) end @@ -197,11 +204,13 @@ shared_examples 'discussion comments' do |resource_name| end it 'updates the submit button text and closes the dropdown' do + button = find(submit_selector) + # on issues page, the submit input is a <button>, on other pages it is <input> - if resource_name == 'issue' - expect(find(submit_selector)).to have_content 'Comment' + if button.tag_name == 'button' + expect(button).to have_content 'Comment' else - expect(find(submit_selector).value).to eq 'Comment' + expect(button.value).to eq 'Comment' end expect(page).not_to have_selector menu_selector diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index f61469f673d..1bd6c25100e 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -2,7 +2,7 @@ # It takes a `issuable_type`, and expect an `issuable`. shared_examples 'issuable record that supports quick actions in its description and notes' do |issuable_type| - include QuickActionsHelpers + include Spec::Support::Helpers::Features::NotesHelpers let(:master) { create(:user) } let(:project) do @@ -61,7 +61,7 @@ shared_examples 'issuable record that supports quick actions in its description context 'with a note containing commands' do it 'creates a note without the commands and interpret the commands accordingly' do assignee = create(:user, username: 'bob') - write_note("Awesome!\n\n/assign @bob\n\n/label ~bug\n\n/milestone %\"ASAP\"") + add_note("Awesome!\n\n/assign @bob\n\n/label ~bug\n\n/milestone %\"ASAP\"") expect(page).to have_content 'Awesome!' expect(page).not_to have_content '/assign @bob' @@ -82,7 +82,7 @@ shared_examples 'issuable record that supports quick actions in its description context 'with a note containing only commands' do it 'does not create a note but interpret the commands accordingly' do assignee = create(:user, username: 'bob') - write_note("/assign @bob\n\n/label ~bug\n\n/milestone %\"ASAP\"") + add_note("/assign @bob\n\n/label ~bug\n\n/milestone %\"ASAP\"") expect(page).not_to have_content '/assign @bob' expect(page).not_to have_content '/label ~bug' @@ -105,7 +105,7 @@ shared_examples 'issuable record that supports quick actions in its description context "when current user can close #{issuable_type}" do it "closes the #{issuable_type}" do - write_note("/close") + add_note("/close") expect(page).not_to have_content '/close' expect(page).to have_content 'Commands applied' @@ -125,7 +125,7 @@ shared_examples 'issuable record that supports quick actions in its description end it "does not close the #{issuable_type}" do - write_note("/close") + add_note("/close") expect(page).not_to have_content 'Commands applied' @@ -142,7 +142,7 @@ shared_examples 'issuable record that supports quick actions in its description context "when current user can reopen #{issuable_type}" do it "reopens the #{issuable_type}" do - write_note("/reopen") + add_note("/reopen") expect(page).not_to have_content '/reopen' expect(page).to have_content 'Commands applied' @@ -162,7 +162,7 @@ shared_examples 'issuable record that supports quick actions in its description end it "does not reopen the #{issuable_type}" do - write_note("/reopen") + add_note("/reopen") expect(page).not_to have_content 'Commands applied' @@ -174,7 +174,7 @@ shared_examples 'issuable record that supports quick actions in its description context "with a note changing the #{issuable_type}'s title" do context "when current user can change title of #{issuable_type}" do it "reopens the #{issuable_type}" do - write_note("/title Awesome new title") + add_note("/title Awesome new title") expect(page).not_to have_content '/title' expect(page).to have_content 'Commands applied' @@ -194,7 +194,7 @@ shared_examples 'issuable record that supports quick actions in its description end it "does not change the #{issuable_type} title" do - write_note("/title Awesome new title") + add_note("/title Awesome new title") expect(page).not_to have_content 'Commands applied' @@ -205,7 +205,7 @@ shared_examples 'issuable record that supports quick actions in its description context "with a note marking the #{issuable_type} as todo" do it "creates a new todo for the #{issuable_type}" do - write_note("/todo") + add_note("/todo") expect(page).not_to have_content '/todo' expect(page).to have_content 'Commands applied' @@ -236,7 +236,7 @@ shared_examples 'issuable record that supports quick actions in its description expect(todo.author).to eq master expect(todo.user).to eq master - write_note("/done") + add_note("/done") expect(page).not_to have_content '/done' expect(page).to have_content 'Commands applied' @@ -249,7 +249,7 @@ shared_examples 'issuable record that supports quick actions in its description it "creates a new todo for the #{issuable_type}" do expect(issuable.subscribed?(master, project)).to be_falsy - write_note("/subscribe") + add_note("/subscribe") expect(page).not_to have_content '/subscribe' expect(page).to have_content 'Commands applied' @@ -266,7 +266,7 @@ shared_examples 'issuable record that supports quick actions in its description it "creates a new todo for the #{issuable_type}" do expect(issuable.subscribed?(master, project)).to be_truthy - write_note("/unsubscribe") + add_note("/unsubscribe") expect(page).not_to have_content '/unsubscribe' expect(page).to have_content 'Commands applied' @@ -277,7 +277,7 @@ shared_examples 'issuable record that supports quick actions in its description context "with a note assigning the #{issuable_type} to the current user" do it "assigns the #{issuable_type} to the current user" do - write_note("/assign me") + add_note("/assign me") expect(page).not_to have_content '/assign me' expect(page).to have_content 'Commands applied' diff --git a/spec/support/helpers/features/notes_helpers.rb b/spec/support/helpers/features/notes_helpers.rb new file mode 100644 index 00000000000..1a1d5853a7a --- /dev/null +++ b/spec/support/helpers/features/notes_helpers.rb @@ -0,0 +1,27 @@ +# These helpers allow you to manipulate with notes. +# +# Usage: +# describe "..." do +# include Spec::Support::Helpers::Features::NotesHelpers +# ... +# +# add_note("Hello world!") +# +module Spec + module Support + module Helpers + module Features + module NotesHelpers + def add_note(text) + Sidekiq::Testing.fake! do + page.within(".js-main-target-form") do + fill_in("note[note]", with: text) + find(".js-comment-submit-button").click + end + end + end + end + end + end + end +end diff --git a/spec/support/helpers/features/sorting_helpers.rb b/spec/support/helpers/features/sorting_helpers.rb new file mode 100644 index 00000000000..50457b64745 --- /dev/null +++ b/spec/support/helpers/features/sorting_helpers.rb @@ -0,0 +1,26 @@ +# These helpers allow you to manipulate with sorting features. +# +# Usage: +# describe "..." do +# include Spec::Support::Helpers::Features::SortingHelpers +# ... +# +# sort_by("Last updated") +# +module Spec + module Support + module Helpers + module Features + module SortingHelpers + def sort_by(value) + find('button.dropdown-toggle').click + + page.within('.content ul.dropdown-menu.dropdown-menu-align-right li') do + click_link(value) + end + end + end + end + end + end +end diff --git a/spec/support/ldap_helpers.rb b/spec/support/ldap_helpers.rb index 081ce0ad7b7..0e87b3d359d 100644 --- a/spec/support/ldap_helpers.rb +++ b/spec/support/ldap_helpers.rb @@ -41,4 +41,9 @@ module LdapHelpers entry end + + def raise_ldap_connection_error + allow_any_instance_of(Gitlab::Auth::LDAP::Adapter) + .to receive(:ldap_search).and_raise(Gitlab::Auth::LDAP::LDAPConnectionError) + end end diff --git a/spec/support/login_helpers.rb b/spec/support/login_helpers.rb index d08183846a0..db34090e971 100644 --- a/spec/support/login_helpers.rb +++ b/spec/support/login_helpers.rb @@ -140,6 +140,10 @@ module LoginHelpers end allow(Gitlab::Auth::OAuth::Provider).to receive_messages(providers: [:saml], config_for: mock_saml_config) stub_omniauth_setting(messages) + stub_saml_authorize_path_helpers + end + + def stub_saml_authorize_path_helpers allow_any_instance_of(Object).to receive(:user_saml_omniauth_authorize_path).and_return('/users/auth/saml') allow_any_instance_of(Object).to receive(:omniauth_authorize_path).with(:user, "saml").and_return('/users/auth/saml') end diff --git a/spec/support/matchers/issuable_matchers.rb b/spec/support/matchers/issuable_matchers.rb new file mode 100644 index 00000000000..f5d9a97051a --- /dev/null +++ b/spec/support/matchers/issuable_matchers.rb @@ -0,0 +1,11 @@ +RSpec::Matchers.define :have_header_with_correct_id_and_link do |level, text, id, parent = ".wiki"| + match do |actual| + node = find("#{parent} h#{level} a#user-content-#{id}") + + expect(node[:href]).to end_with("##{id}") + + # Work around a weird Capybara behavior where calling `parent` on a node + # returns the whole document, not the node's actual parent element + expect(find(:xpath, "#{node.path}/..").text).to eq(text) + end +end diff --git a/spec/support/migrations_helpers.rb b/spec/support/migrations_helpers.rb index 6bf976a2cf9..5d6f662e8fe 100644 --- a/spec/support/migrations_helpers.rb +++ b/spec/support/migrations_helpers.rb @@ -1,6 +1,9 @@ module MigrationsHelpers def table(name) - Class.new(ActiveRecord::Base) { self.table_name = name } + Class.new(ActiveRecord::Base) do + self.table_name = name + self.inheritance_column = :_type_disabled + end end def migrations_paths diff --git a/spec/support/quick_actions_helpers.rb b/spec/support/quick_actions_helpers.rb deleted file mode 100644 index 361190aa352..00000000000 --- a/spec/support/quick_actions_helpers.rb +++ /dev/null @@ -1,10 +0,0 @@ -module QuickActionsHelpers - def write_note(text) - Sidekiq::Testing.fake! do - page.within('.js-main-target-form') do - fill_in 'note[note]', with: text - find('.js-comment-submit-button').click - end - end - end -end diff --git a/spec/support/shared_examples/serializers/note_entity_examples.rb b/spec/support/shared_examples/serializers/note_entity_examples.rb new file mode 100644 index 00000000000..9097c8e5513 --- /dev/null +++ b/spec/support/shared_examples/serializers/note_entity_examples.rb @@ -0,0 +1,42 @@ +shared_examples 'note entity' do + subject { entity.as_json } + + context 'basic note' do + it 'exposes correct elements' do + expect(subject).to include(:type, :author, :note, :note_html, :current_user, + :discussion_id, :emoji_awardable, :award_emoji, :report_abuse_path, :attachment) + end + + it 'does not expose elements for specific notes cases' do + expect(subject).not_to include(:last_edited_by, :last_edited_at, :system_note_icon_name) + end + + it 'exposes author correctly' do + expect(subject[:author]).to include(:id, :name, :username, :state, :avatar_url, :path) + end + + it 'does not expose web_url for author' do + expect(subject[:author]).not_to include(:web_url) + end + end + + context 'when note was edited' do + before do + note.update(updated_at: 1.minute.from_now, updated_by: user) + end + + it 'exposes last_edited_at and last_edited_by elements' do + expect(subject).to include(:last_edited_at, :last_edited_by) + end + end + + context 'when note is a system note' do + before do + note.update(system: true) + end + + it 'exposes system_note_icon_name element' do + expect(subject).to include(:system_note_icon_name) + end + end +end diff --git a/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb b/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb index cd9974cd6e2..6352f1527cd 100644 --- a/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb +++ b/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb @@ -61,12 +61,18 @@ shared_examples "migrates" do |to_store:, from_store: nil| expect { migrate(to) }.not_to change { file.exists? } end - context 'when migrate! is not oqqupied by another process' do + context 'when migrate! is not occupied by another process' do it 'executes migrate!' do expect(subject).to receive(:object_store=).at_least(1) migrate(to) end + + it 'executes use_file' do + expect(subject).to receive(:unsafe_use_file).once + + subject.use_file + end end context 'when migrate! is occupied by another process' do @@ -79,7 +85,13 @@ shared_examples "migrates" do |to_store:, from_store: nil| it 'does not execute migrate!' do expect(subject).not_to receive(:unsafe_migrate!) - expect { migrate(to) }.to raise_error('Already running') + expect { migrate(to) }.to raise_error('exclusive lease already taken') + end + + it 'does not execute use_file' do + expect(subject).not_to receive(:unsafe_use_file) + + expect { subject.use_file }.to raise_error('exclusive lease already taken') end after do diff --git a/spec/support/sorting_helper.rb b/spec/support/sorting_helper.rb deleted file mode 100644 index 577518d726c..00000000000 --- a/spec/support/sorting_helper.rb +++ /dev/null @@ -1,18 +0,0 @@ -# Helper allows you to sort items -# -# Params -# value - value for sorting -# -# Usage: -# include SortingHelper -# -# sorting_by('Oldest updated') -# -module SortingHelper - def sorting_by(value) - find('button.dropdown-toggle').click - page.within('.content ul.dropdown-menu.dropdown-menu-align-right li') do - click_link value - end - end -end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index f14e69b1041..d87f265cdf0 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -62,6 +62,7 @@ module TestEnv }.freeze TMP_TEST_PATH = Rails.root.join('tmp', 'tests', '**') + REPOS_STORAGE = 'default'.freeze # Test environment # @@ -225,7 +226,7 @@ module TestEnv end def repos_path - Gitlab.config.repositories.storages.default.legacy_disk_path + Gitlab.config.repositories.storages[REPOS_STORAGE].legacy_disk_path end def backup_path diff --git a/spec/tasks/gitlab/uploads/migrate_rake_spec.rb b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb index b778d26060d..6fcfae358ec 100644 --- a/spec/tasks/gitlab/uploads/migrate_rake_spec.rb +++ b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb @@ -1,10 +1,9 @@ require 'rake_helper' describe 'gitlab:uploads:migrate rake tasks' do - let!(:projects) { create_list(:project, 10, :with_avatar) } - let(:model_class) { Project } - let(:uploader_class) { AvatarUploader } - let(:mounted_as) { :avatar } + let(:model_class) { nil } + let(:uploader_class) { nil } + let(:mounted_as) { nil } let(:batch_size) { 3 } before do @@ -20,9 +19,125 @@ describe 'gitlab:uploads:migrate rake tasks' do run_rake_task("gitlab:uploads:migrate", *args) end - it 'enqueue jobs in batch' do - expect(ObjectStorage::MigrateUploadsWorker).to receive(:enqueue!).exactly(4).times + shared_examples 'enqueue jobs in batch' do |batch:| + it do + expect(ObjectStorage::MigrateUploadsWorker) + .to receive(:perform_async).exactly(batch).times + .and_return("A fake job.") - run + run + end + end + + context "for AvatarUploader" do + let(:uploader_class) { AvatarUploader } + let(:mounted_as) { :avatar } + + context "for Project" do + let(:model_class) { Project } + let!(:projects) { create_list(:project, 10, :with_avatar) } + + it_behaves_like 'enqueue jobs in batch', batch: 4 + + context 'Upload has store = nil' do + before do + Upload.where(model: projects).update_all(store: nil) + end + + it_behaves_like 'enqueue jobs in batch', batch: 4 + end + end + + context "for Group" do + let(:model_class) { Group } + + before do + create_list(:group, 10, :with_avatar) + end + + it_behaves_like 'enqueue jobs in batch', batch: 4 + end + + context "for User" do + let(:model_class) { User } + + before do + create_list(:user, 10, :with_avatar) + end + + it_behaves_like 'enqueue jobs in batch', batch: 4 + end + end + + context "for AttachmentUploader" do + let(:uploader_class) { AttachmentUploader } + + context "for Note" do + let(:model_class) { Note } + let(:mounted_as) { :attachment } + + before do + create_list(:note, 10, :with_attachment) + end + + it_behaves_like 'enqueue jobs in batch', batch: 4 + end + + context "for Appearance" do + let(:model_class) { Appearance } + let(:mounted_as) { :logo } + + before do + create(:appearance, :with_logos) + end + + %i(logo header_logo).each do |mount| + it_behaves_like 'enqueue jobs in batch', batch: 1 do + let(:mounted_as) { mount } + end + end + end + end + + context "for FileUploader" do + let(:uploader_class) { FileUploader } + let(:model_class) { Project } + + before do + create_list(:project, 10) do |model| + uploader_class.new(model) + .store!(fixture_file_upload('spec/fixtures/doc_sample.txt')) + end + end + + it_behaves_like 'enqueue jobs in batch', batch: 4 + end + + context "for PersonalFileUploader" do + let(:uploader_class) { PersonalFileUploader } + let(:model_class) { PersonalSnippet } + + before do + create_list(:personal_snippet, 10) do |model| + uploader_class.new(model) + .store!(fixture_file_upload('spec/fixtures/doc_sample.txt')) + end + end + + it_behaves_like 'enqueue jobs in batch', batch: 4 + end + + context "for NamespaceFileUploader" do + let(:uploader_class) { NamespaceFileUploader } + let(:model_class) { Snippet } + + before do + create_list(:snippet, 10) do |model| + uploader_class.new(model) + .store!(fixture_file_upload('spec/fixtures/doc_sample.txt')) + end + end + + it_behaves_like 'enqueue jobs in batch', batch: 4 end end diff --git a/spec/uploaders/object_storage_spec.rb b/spec/uploaders/object_storage_spec.rb index 1d406c71955..59e02fecbce 100644 --- a/spec/uploaders/object_storage_spec.rb +++ b/spec/uploaders/object_storage_spec.rb @@ -308,6 +308,30 @@ describe ObjectStorage do it { is_expected.to eq(remote_directory) } end + context 'when file is in use' do + def when_file_is_in_use + uploader.use_file do + yield + end + end + + it 'cannot migrate' do + when_file_is_in_use do + expect(uploader).not_to receive(:unsafe_migrate!) + + expect { uploader.migrate!(described_class::Store::REMOTE) }.to raise_error('exclusive lease already taken') + end + end + + it 'cannot use_file' do + when_file_is_in_use do + expect(uploader).not_to receive(:unsafe_use_file) + + expect { uploader.use_file }.to raise_error('exclusive lease already taken') + end + end + end + describe '#fog_credentials' do let(:connection) { Settingslogic.new("provider" => "AWS") } diff --git a/spec/uploaders/workers/object_storage/background_move_worker_spec.rb b/spec/uploaders/workers/object_storage/background_move_worker_spec.rb new file mode 100644 index 00000000000..b34f427fd8a --- /dev/null +++ b/spec/uploaders/workers/object_storage/background_move_worker_spec.rb @@ -0,0 +1,146 @@ +require 'spec_helper' + +describe ObjectStorage::BackgroundMoveWorker do + let(:local) { ObjectStorage::Store::LOCAL } + let(:remote) { ObjectStorage::Store::REMOTE } + + def perform + described_class.perform_async(uploader_class.name, subject_class, file_field, subject_id) + end + + context 'for LFS' do + let!(:lfs_object) { create(:lfs_object, :with_file, file_store: local) } + let(:uploader_class) { LfsObjectUploader } + let(:subject_class) { LfsObject } + let(:file_field) { :file } + let(:subject_id) { lfs_object.id } + + context 'when object storage is enabled' do + before do + stub_lfs_object_storage(background_upload: true) + end + + it 'uploads object to storage' do + expect { perform }.to change { lfs_object.reload.file_store }.from(local).to(remote) + end + + context 'when background upload is disabled' do + before do + allow(Gitlab.config.lfs.object_store).to receive(:background_upload) { false } + end + + it 'is skipped' do + expect { perform }.not_to change { lfs_object.reload.file_store } + end + end + end + + context 'when object storage is disabled' do + before do + stub_lfs_object_storage(enabled: false) + end + + it "doesn't migrate files" do + perform + + expect(lfs_object.reload.file_store).to eq(local) + end + end + end + + context 'for legacy artifacts' do + let(:build) { create(:ci_build, :legacy_artifacts) } + let(:uploader_class) { LegacyArtifactUploader } + let(:subject_class) { Ci::Build } + let(:file_field) { :artifacts_file } + let(:subject_id) { build.id } + + context 'when local storage is used' do + let(:store) { local } + + context 'and remote storage is defined' do + before do + stub_artifacts_object_storage(background_upload: true) + end + + it "migrates file to remote storage" do + perform + + expect(build.reload.artifacts_file_store).to eq(remote) + end + + context 'for artifacts_metadata' do + let(:file_field) { :artifacts_metadata } + + it 'migrates metadata to remote storage' do + perform + + expect(build.reload.artifacts_metadata_store).to eq(remote) + end + end + end + end + end + + context 'for job artifacts' do + let(:artifact) { create(:ci_job_artifact, :archive) } + let(:uploader_class) { JobArtifactUploader } + let(:subject_class) { Ci::JobArtifact } + let(:file_field) { :file } + let(:subject_id) { artifact.id } + + context 'when local storage is used' do + let(:store) { local } + + context 'and remote storage is defined' do + before do + stub_artifacts_object_storage(background_upload: true) + end + + it "migrates file to remote storage" do + perform + + expect(artifact.reload.file_store).to eq(remote) + end + end + end + end + + context 'for uploads' do + let!(:project) { create(:project, :with_avatar) } + let(:uploader_class) { AvatarUploader } + let(:file_field) { :avatar } + + context 'when local storage is used' do + let(:store) { local } + + context 'and remote storage is defined' do + before do + stub_uploads_object_storage(uploader_class, background_upload: true) + end + + describe 'supports using the model' do + let(:subject_class) { project.class } + let(:subject_id) { project.id } + + it "migrates file to remote storage" do + perform + + expect(project.reload.avatar.file_storage?).to be_falsey + end + end + + describe 'supports using the Upload' do + let(:subject_class) { Upload } + let(:subject_id) { project.avatar.upload.id } + + it "migrates file to remote storage" do + perform + + expect(project.reload.avatar.file_storage?).to be_falsey + end + end + end + end + end +end diff --git a/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb b/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb new file mode 100644 index 00000000000..7a7dcb71680 --- /dev/null +++ b/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb @@ -0,0 +1,119 @@ +require 'spec_helper' + +describe ObjectStorage::MigrateUploadsWorker, :sidekiq do + shared_context 'sanity_check! fails' do + before do + expect(described_class).to receive(:sanity_check!).and_raise(described_class::SanityCheckError) + end + end + + let!(:projects) { create_list(:project, 10, :with_avatar) } + let(:uploads) { Upload.all } + let(:model_class) { Project } + let(:mounted_as) { :avatar } + let(:to_store) { ObjectStorage::Store::REMOTE } + + before do + stub_uploads_object_storage(AvatarUploader) + end + + describe '.enqueue!' do + def enqueue! + described_class.enqueue!(uploads, Project, mounted_as, to_store) + end + + it 'is guarded by .sanity_check!' do + expect(described_class).to receive(:perform_async) + expect(described_class).to receive(:sanity_check!) + + enqueue! + end + + context 'sanity_check! fails' do + include_context 'sanity_check! fails' + + it 'does not enqueue a job' do + expect(described_class).not_to receive(:perform_async) + + expect { enqueue! }.to raise_error(described_class::SanityCheckError) + end + end + end + + describe '.sanity_check!' do + shared_examples 'raises a SanityCheckError' do + let(:mount_point) { nil } + + it do + expect { described_class.sanity_check!(uploads, model_class, mount_point) } + .to raise_error(described_class::SanityCheckError) + end + end + + context 'uploader types mismatch' do + let!(:outlier) { create(:upload, uploader: 'FileUploader') } + + include_examples 'raises a SanityCheckError' + end + + context 'model types mismatch' do + let!(:outlier) { create(:upload, model_type: 'Potato') } + + include_examples 'raises a SanityCheckError' + end + + context 'mount point not found' do + include_examples 'raises a SanityCheckError' do + let(:mount_point) { :potato } + end + end + end + + describe '#perform' do + def perform + described_class.new.perform(uploads.ids, model_class.to_s, mounted_as, to_store) + rescue ObjectStorage::MigrateUploadsWorker::Report::MigrationFailures + # swallow + end + + shared_examples 'outputs correctly' do |success: 0, failures: 0| + total = success + failures + + if success > 0 + it 'outputs the reports' do + expect(Rails.logger).to receive(:info).with(%r{Migrated #{success}/#{total} files}) + + perform + end + end + + if failures > 0 + it 'outputs upload failures' do + expect(Rails.logger).to receive(:warn).with(/Error .* I am a teapot/) + + perform + end + end + end + + it_behaves_like 'outputs correctly', success: 10 + + it 'migrates files' do + perform + + aggregate_failures do + projects.each do |project| + expect(project.reload.avatar.upload.local?).to be_falsey + end + end + end + + context 'migration is unsuccessful' do + before do + allow_any_instance_of(ObjectStorage::Concern).to receive(:migrate!).and_raise(CarrierWave::UploadError, "I am a teapot.") + end + + it_behaves_like 'outputs correctly', failures: 10 + end + end +end diff --git a/spec/workers/project_export_worker_spec.rb b/spec/workers/project_export_worker_spec.rb new file mode 100644 index 00000000000..8899969c178 --- /dev/null +++ b/spec/workers/project_export_worker_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe ProjectExportWorker do + let!(:user) { create(:user) } + let!(:project) { create(:project) } + + subject { described_class.new } + + describe '#perform' do + context 'when it succeeds' do + it 'calls the ExportService' do + expect_any_instance_of(::Projects::ImportExport::ExportService).to receive(:execute) + + subject.perform(user.id, project.id, { 'klass' => 'Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy' }) + end + end + + context 'when it fails' do + it 'raises an exception when params are invalid' do + expect_any_instance_of(::Projects::ImportExport::ExportService).not_to receive(:execute) + + expect { subject.perform(1234, project.id, {}) }.to raise_exception(ActiveRecord::RecordNotFound) + expect { subject.perform(user.id, 1234, {}) }.to raise_exception(ActiveRecord::RecordNotFound) + expect { subject.perform(user.id, project.id, { 'klass' => 'Whatever' }) }.to raise_exception(Gitlab::ImportExport::AfterExportStrategyBuilder::StrategyNotFoundError) + end + end + end +end diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb index 6c66658d8c3..4b3c1736ea0 100644 --- a/spec/workers/repository_fork_worker_spec.rb +++ b/spec/workers/repository_fork_worker_spec.rb @@ -9,70 +9,91 @@ describe RepositoryForkWorker do describe "#perform" do let(:project) { create(:project, :repository) } - let(:fork_project) { create(:project, :repository, :import_scheduled, forked_from_project: project) } let(:shell) { Gitlab::Shell.new } + let(:fork_project) { create(:project, :repository, :import_scheduled, forked_from_project: project) } - before do - allow(subject).to receive(:gitlab_shell).and_return(shell) - end + shared_examples 'RepositoryForkWorker performing' do + before do + allow(subject).to receive(:gitlab_shell).and_return(shell) + end - def perform! - subject.perform(fork_project.id, '/test/path', project.disk_path) - end + def expect_fork_repository + expect(shell).to receive(:fork_repository).with( + 'default', + project.disk_path, + fork_project.repository_storage, + fork_project.disk_path + ) + end - def expect_fork_repository - expect(shell).to receive(:fork_repository).with( - '/test/path', - project.disk_path, - fork_project.repository_storage_path, - fork_project.disk_path - ) - end + describe 'when a worker was reset without cleanup' do + let(:jid) { '12345678' } - describe 'when a worker was reset without cleanup' do - let(:jid) { '12345678' } + it 'creates a new repository from a fork' do + allow(subject).to receive(:jid).and_return(jid) - it 'creates a new repository from a fork' do - allow(subject).to receive(:jid).and_return(jid) + expect_fork_repository.and_return(true) + perform! + end + end + + it "creates a new repository from a fork" do expect_fork_repository.and_return(true) perform! end - end - it "creates a new repository from a fork" do - expect_fork_repository.and_return(true) + it 'protects the default branch' do + expect_fork_repository.and_return(true) - perform! - end + perform! + + expect(fork_project.protected_branches.first.name).to eq(fork_project.default_branch) + end + + it 'flushes various caches' do + expect_fork_repository.and_return(true) - it 'protects the default branch' do - expect_fork_repository.and_return(true) + expect_any_instance_of(Repository).to receive(:expire_emptiness_caches) + .and_call_original - perform! + expect_any_instance_of(Repository).to receive(:expire_exists_cache) + .and_call_original - expect(fork_project.protected_branches.first.name).to eq(fork_project.default_branch) - end + perform! + end + + it "handles bad fork" do + error_message = "Unable to fork project #{fork_project.id} for repository #{project.disk_path} -> #{fork_project.disk_path}" - it 'flushes various caches' do - expect_fork_repository.and_return(true) + expect_fork_repository.and_return(false) - expect_any_instance_of(Repository).to receive(:expire_emptiness_caches) - .and_call_original + expect { perform! }.to raise_error(StandardError, error_message) + end + end - expect_any_instance_of(Repository).to receive(:expire_exists_cache) - .and_call_original + context 'only project ID passed' do + def perform! + subject.perform(fork_project.id) + end - perform! + it_behaves_like 'RepositoryForkWorker performing' end - it "handles bad fork" do - error_message = "Unable to fork project #{fork_project.id} for repository #{project.disk_path} -> #{fork_project.disk_path}" + context 'project ID, storage and repo paths passed' do + def perform! + subject.perform(fork_project.id, TestEnv.repos_path, project.disk_path) + end - expect_fork_repository.and_return(false) + it_behaves_like 'RepositoryForkWorker performing' - expect { perform! }.to raise_error(StandardError, error_message) + it 'logs a message about forking with old-style arguments' do + allow(Rails.logger).to receive(:info).with(anything) # To compensate for other logs + expect(Rails.logger).to receive(:info).with("Project #{fork_project.id} is being forked using old-style arguments.") + + perform! + end end end end diff --git a/yarn.lock b/yarn.lock index 584951b5da0..af7bda5d562 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3605,7 +3605,7 @@ fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" -fsevents@^1.0.0, fsevents@^1.1.3: +fsevents@^1.0.0: version "1.1.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.3.tgz#11f82318f5fe7bb2cd22965a108e9306208216d8" dependencies: |