diff options
1208 files changed, 24196 insertions, 7630 deletions
diff --git a/.babelrc.js b/.babelrc.js index 27caf378b99..bfcc7d96634 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -35,4 +35,10 @@ if (BABEL_ENV === 'karma' || BABEL_ENV === 'coverage') { plugins.push('babel-plugin-rewire'); } +// Jest is running in node environment +if (BABEL_ENV === 'jest') { + plugins.push('transform-es2015-modules-commonjs'); + plugins.push('dynamic-import-node'); +} + module.exports = { presets, plugins }; diff --git a/.eslintignore b/.eslintignore index 33a8186fade..f78840e67be 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,6 +2,7 @@ /config/ /builds/ /coverage/ +/coverage-frontend/ /coverage-javascript/ /node_modules/ /public/ diff --git a/.gitignore b/.gitignore index aecaae95b8c..65f61e1fade 100644 --- a/.gitignore +++ b/.gitignore @@ -78,5 +78,5 @@ eslint-report.html /plugins/* /.gitlab_pages_secret package-lock.json -/junit_rspec.xml -/junit_karma.xml +/junit_*.xml +/coverage-frontend/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2c521b03863..46604317232 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -48,6 +48,7 @@ after_script: stages: - build - prepare + - merge - test - post-test - pages @@ -439,6 +440,50 @@ setup-test-env: - config/secrets.yml - vendor/gitaly-ruby +# GitLab Review apps +.review-base: &review-base + <<: *dedicated-no-docs-no-db-pull-cache-job + image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base + stage: test + cache: {} + dependencies: [] + environment: &review-environment + name: review/${CI_COMMIT_REF_NAME} + url: https://gitlab-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN} + only: + refs: + - branches@gitlab-org/gitlab-ce + - branches@gitlab-org/gitlab-ee + kubernetes: active + except: + refs: + - master + - /(^docs[\/-].*|.*-docs$)/ + before_script: [] + +.review-docker: &review-docker + <<: *review-base + image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-qa-alpine + services: + - docker:stable-dind + tags: + - gitlab-org + - docker + variables: &review-docker-variables + GIT_DEPTH: "1" + DOCKER_DRIVER: overlay2 + DOCKER_HOST: tcp://docker:2375 + LATEST_QA_IMAGE: "gitlab/${CI_PROJECT_NAME}-qa:nightly" + QA_IMAGE: "${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab/${CI_PROJECT_NAME}-qa:${CI_COMMIT_REF_SLUG}" + +build-qa-image: + <<: *review-docker + stage: prepare + script: + - time docker build --cache-from ${LATEST_QA_IMAGE} --tag ${QA_IMAGE} ./qa/ + - echo "${CI_JOB_TOKEN}" | docker login --username gitlab-ci-token --password-stdin ${CI_REGISTRY} + - time docker push ${QA_IMAGE} + danger-review: <<: *pull-cache image: registry.gitlab.com/gitlab-org/gitlab-build-images:danger @@ -622,7 +667,7 @@ gitlab:assets:compile: only: - //@gitlab-org/gitlab-ce - //@gitlab-org/gitlab-ee - - //@gitlab/gitabhq + - //@gitlab/gitlabhq - //@gitlab/gitlab-ee tags: - gitlab-org-delivery @@ -654,6 +699,32 @@ karma: reports: junit: junit_karma.xml +jest: + <<: *dedicated-no-docs-and-no-qa-pull-cache-job + <<: *use-pg + dependencies: + - compile-assets + - setup-test-env + script: + - scripts/gitaly-test-spawn + - date + - bundle exec rake karma:fixtures + - date + - yarn jest --ci --coverage + artifacts: + name: coverage-frontend + expire_in: 31d + when: always + paths: + - coverage-frontend/ + - junit_jest.xml + reports: + junit: junit_jest.xml + cache: + key: jest + paths: + - tmp/jest/jest/ + code_quality: <<: *dedicated-no-docs-no-db-pull-cache-job image: docker:stable @@ -876,85 +947,102 @@ no_ee_check: - //@gitlab-org/gitlab-ce # GitLab Review apps -review: - image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base - stage: test +review-deploy: + <<: *review-base + retry: 2 allow_failure: true - before_script: - - gem install gitlab --no-document variables: GIT_DEPTH: "1" - HOST_SUFFIX: "$CI_ENVIRONMENT_SLUG" - DOMAIN: "-$CI_ENVIRONMENT_SLUG.$REVIEW_APPS_DOMAIN" + HOST_SUFFIX: "${CI_ENVIRONMENT_SLUG}" + DOMAIN: "-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}" GITLAB_HELM_CHART_REF: "master" + API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" + environment: + <<: *review-environment + on_stop: review-stop + before_script: + - apk update && apk add jq + - gem install gitlab --no-document script: - export GITLAB_SHELL_VERSION=$(<GITLAB_SHELL_VERSION) - export GITALY_VERSION=$(<GITALY_SERVER_VERSION) - export GITLAB_WORKHORSE_VERSION=$(<GITLAB_WORKHORSE_VERSION) - source ./scripts/review_apps/review-apps.sh + - wait_for_job_to_be_done "gitlab:assets:compile" - BUILD_TRIGGER_TOKEN=$REVIEW_APPS_BUILD_TRIGGER_TOKEN ./scripts/trigger-build cng - check_kube_domain - download_gitlab_chart - ensure_namespace - install_tiller - install_external_dns - - deploy - environment: - name: review/$CI_COMMIT_REF_NAME - url: https://gitlab-$CI_ENVIRONMENT_SLUG.$REVIEW_APPS_DOMAIN - on_stop: stop_review - only: - refs: - - branches@gitlab-org/gitlab-ce - - branches@gitlab-org/gitlab-ee - kubernetes: active - except: - refs: - - master - - /(^docs[\/-].*|.*-docs$)/ + - time deploy + - add_license + +.review-qa-base: &review-qa-base + <<: *review-docker + retry: 2 + allow_failure: true + variables: + <<: *review-docker-variables + API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" + QA_ARTIFACTS_DIR: "${CI_PROJECT_DIR}/qa" + QA_CAN_TEST_GIT_PROTOCOL_V2: "false" + GITLAB_USERNAME: "root" + GITLAB_PASSWORD: "${REVIEW_APPS_ROOT_PASSWORD}" + GITLAB_ADMIN_USERNAME: "root" + GITLAB_ADMIN_PASSWORD: "${REVIEW_APPS_ROOT_PASSWORD}" + GITHUB_ACCESS_TOKEN: "${REVIEW_APPS_QA_GITHUB_ACCESS_TOKEN}" + EE_LICENSE: "${REVIEW_APPS_EE_LICENSE}" + QA_DEBUG: "true" + artifacts: + paths: + - ./qa/gitlab-qa-run-* + expire_in: 7 days + when: always + before_script: + - echo "${QA_IMAGE}" + - echo "${CI_ENVIRONMENT_URL}" + - apk update && apk add curl jq + - source ./scripts/review_apps/review-apps.sh + - gem install gitlab-qa --no-document ${GITLAB_QA_VERSION:+ --version ${GITLAB_QA_VERSION}} + - wait_for_job_to_be_done "review-deploy" -stop_review: +review-qa-smoke: + <<: *review-qa-base + script: + - gitlab-qa Test::Instance::Smoke "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}" + +review-qa-all: + <<: *review-qa-base + script: + - gitlab-qa Test::Instance::Any "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}" + when: manual + +review-stop: + <<: *review-base <<: *single-script-job image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base - stage: test allow_failure: true - cache: {} - dependencies: [] variables: + <<: *single-script-job-variables SCRIPT_NAME: "review_apps/review-apps.sh" + when: manual + environment: + <<: *review-environment + action: stop script: - source $(basename "${SCRIPT_NAME}") - delete - cleanup - when: manual - environment: - name: review/$CI_COMMIT_REF_NAME - action: stop - only: - refs: - - branches@gitlab-org/gitlab-ce - - branches@gitlab-org/gitlab-ee - kubernetes: active - except: - - master - - /(^docs[\/-].*|.*-docs$)/ -schedule:review_apps_cleanup: - <<: *dedicated-no-docs-pull-cache-job - image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base +schedule:review-cleanup: + <<: *review-base stage: build allow_failure: true - cache: {} - dependencies: [] - before_script: - - gem install gitlab --no-document variables: GIT_DEPTH: "1" - script: - - ruby -rrubygems scripts/review_apps/automated_cleanup.rb environment: name: review/auto-cleanup - action: stop only: refs: - schedules@gitlab-org/gitlab-ce @@ -963,3 +1051,7 @@ schedule:review_apps_cleanup: except: - tags - /(^docs[\/-].*|.*-docs$)/ + before_script: + - gem install gitlab --no-document + script: + - ruby -rrubygems scripts/review_apps/automated_cleanup.rb diff --git a/.gitlab/CODEOWNERS.disabled b/.gitlab/CODEOWNERS index a4b773b15a9..a4b773b15a9 100644 --- a/.gitlab/CODEOWNERS.disabled +++ b/.gitlab/CODEOWNERS diff --git a/.gitlab/issue_templates/Security developer workflow.md b/.gitlab/issue_templates/Security developer workflow.md index 69cf7fe1548..08651195d98 100644 --- a/.gitlab/issue_templates/Security developer workflow.md +++ b/.gitlab/issue_templates/Security developer workflow.md @@ -16,6 +16,7 @@ Set the title to: `[Security] Description of the original issue` - [ ] Add a link to the MR to the [links section](#links) - [ ] Add a link to an EE MR if required - [ ] Make sure the MR remains in-progress and gets approved after the review cycle, **but never merged**. +- [ ] Add a link to this issue on the original security issue. #### Backports @@ -37,6 +38,7 @@ Set the title to: `[Security] Description of the original issue` - [ ] Fill in any upgrade notes that users may need to take into account in the [details section](#details) - [ ] Add Yes/No and further details if needed to the migration and settings columns in the [details section](#details) - [ ] Add the nickname of the external user who found the issue (and/or HackerOne profile) to the Thanks row in the [details section](#details) +- [ ] Once your `master` MR is merged, comment on the original security issue with a link to that MR indicating the issue is fixed. ### Summary diff --git a/CHANGELOG.md b/CHANGELOG.md index c65b584e3c1..d1e324c5518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,52 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 11.5.3 (2018-12-06) + +### Security (1 change) + +- Prevent a path traversal attack on global file templates. + + +## 11.5.2 (2018-12-03) + +### Removed (1 change) + +- Removed Site Statistics optimization as it was causing problems. !23314 + +### Fixed (6 changes, 1 of them is from the community) + +- Display impersonation token value only after creation. !22916 +- Fix not render emoji in filter dropdown. !23112 (Hiroyuki Sato) +- Fixes stuck tooltip on stop env button. !23244 +- Correctly handle data-loss scenarios when encrypting columns. !23306 +- Clear BatchLoader context between Sidekiq jobs. !23308 +- Fix handling of filenames with hash characters in tree view. !23368 + + +## 11.5.1 (2018-11-26) + +### Security (17 changes) + +- Escape user fullname while rendering autocomplete template to prevent XSS. +- Fix CRLF vulnerability in Project hooks. +- Fix possible XSS attack in Markdown urls with spaces. +- Redact sensitive information on gitlab-workhorse log. +- Do not follow redirects in Prometheus service when making http requests to the configured api url. +- Don't expose confidential information in commit message list. +- Provide email notification when a user changes their email address. +- Restrict Personal Access Tokens to API scope on web requests. +- Resolve reflected XSS in Ouath authorize window. +- Fix SSRF in project integrations. +- Fixed ability to comment on locked/confidential issues. +- Fixed ability of guest users to edit/delete comments on locked or confidential issues. +- Fix milestone promotion authorization check. +- Configure mermaid to not render HTML content in diagrams. +- Fix a possible symlink time of check to time of use race condition in GitLab Pages. +- Removed ability to see private group names when the group id is entered in the url. +- Fix stored XSS for Environments. + + ## 11.5.0 (2018-11-22) ### Security (10 changes, 1 of them is from the community) @@ -264,6 +310,44 @@ entry. - Disables stop environment button while the deploy is in progress. +## 11.4.9 (2018-12-03) + +### Fixed (2 changes) + +- Display impersonation token value only after creation. !22916 +- Correctly handle data-loss scenarios when encrypting columns. !23306 + + +## 11.4.8 (2018-11-27) + +### Security (24 changes) + +- Escape entity title while autocomplete template rendering to prevent XSS. !2571 +- Resolve reflected XSS in Ouath authorize window. +- Fix XSS in merge request source branch name. +- Escape user fullname while rendering autocomplete template to prevent XSS. +- Fix CRLF vulnerability in Project hooks. +- Fix possible XSS attack in Markdown urls with spaces. +- Redact sensitive information on gitlab-workhorse log. +- Do not follow redirects in Prometheus service when making http requests to the configured api url. +- Persist only SHA digest of PersonalAccessToken#token. +- Don't expose confidential information in commit message list. +- Provide email notification when a user changes their email address. +- Restrict Personal Access Tokens to API scope on web requests. +- Redact personal tokens in unsubscribe links. +- Fix SSRF in project integrations. +- Fixed ability to comment on locked/confidential issues. +- Fixed ability of guest users to edit/delete comments on locked or confidential issues. +- Fix milestone promotion authorization check. +- Monkey kubeclient to not follow any redirects. +- Configure mermaid to not render HTML content in diagrams. +- Fix a possible symlink time of check to time of use race condition in GitLab Pages. +- Removed ability to see private group names when the group id is entered in the url. +- Fix stored XSS for Environments. +- Prevent SSRF attacks in HipChat integration. +- Validate Wiki attachments are valid temporary files. + + ## 11.4.7 (2018-11-20) - No changes. @@ -544,6 +628,52 @@ entry. - Check frozen string in style builds. (gfyoung) +## 11.3.12 (2018-12-06) + +### Security (1 change) + +- Prevent a path traversal attack on global file templates. + + +## 11.3.11 (2018-11-26) + +### Security (33 changes) + +- Filter user sensitive data from discussions JSON. !2537 +- Escape entity title while autocomplete template rendering to prevent XSS. !2557 +- Restrict Personal Access Tokens to API scope on web requests. +- Fix XSS in merge request source branch name. +- Escape user fullname while rendering autocomplete template to prevent XSS. +- Fix CRLF vulnerability in Project hooks. +- Fix possible XSS attack in Markdown urls with spaces. +- Redact sensitive information on gitlab-workhorse log. +- Set timeout for syntax highlighting. +- Do not follow redirects in Prometheus service when making http requests to the configured api url. +- Persist only SHA digest of PersonalAccessToken#token. +- Sanitize JSON data properly to fix XSS on Issue details page. +- Don't expose confidential information in commit message list. +- Markdown API no longer displays confidential title references unless authorized. +- Provide email notification when a user changes their email address. +- Properly filter private references from system notes. +- Redact personal tokens in unsubscribe links. +- Resolve reflected XSS in Ouath authorize window. +- Fix SSRF in project integrations. +- Fix stored XSS in merge requests from imported repository. +- Fixed ability to comment on locked/confidential issues. +- Fixed ability of guest users to edit/delete comments on locked or confidential issues. +- Fix milestone promotion authorization check. +- Monkey kubeclient to not follow any redirects. +- Configure mermaid to not render HTML content in diagrams. +- Redact confidential events in the API. +- Fix xss vulnerability sourced from package.json. +- Fix a possible symlink time of check to time of use race condition in GitLab Pages. +- Removed ability to see private group names when the group id is entered in the url. +- Fix stored XSS for Environments. +- Block loopback addresses in UrlBlocker. +- Prevent SSRF attacks in HipChat integration. +- Validate Wiki attachments are valid temporary files. + + ## 11.3.10 (2018-11-18) ### Security (1 change) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dc8ac40dd4..4304f6c8744 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -181,4 +181,4 @@ This [documentation](doc/development/contributing/merge_request_workflow.md) has ## Style guides -This [documentation](doc/development/contributing/design.md) has been moved. +This [documentation](doc/development/contributing/style_guides.md) has been moved. diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 9084fa2f716..bd8bf882d06 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.1.0 +1.7.0 diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index f0bb29e7638..3a3cd8cc8b0 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -1.3.0 +1.3.1 diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 6da4de57dc6..016dac34bf9 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -8.4.1 +8.4.3 diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index 21c8c7b46b8..18bb4182dd0 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -7.1.1 +7.5.0 @@ -5,8 +5,13 @@ end gem_versions = {} gem_versions['activerecord_sane_schema_dumper'] = rails5? ? '1.0' : '0.2' -gem_versions['rails'] = rails5? ? '5.0.7' : '4.2.10' +gem_versions['rails'] = rails5? ? '5.0.7' : '4.2.11' gem_versions['rails-i18n'] = rails5? ? '~> 5.1' : '~> 4.0.9' + +# The 2.0.6 version of rack requires monkeypatch to be present in +# `config.ru`. This can be removed once a new update for Rack +# is available that contains https://github.com/rack/rack/pull/1201. +gem_versions['rack'] = rails5? ? '2.0.6' : '1.6.11' # --- The end of special code for migrating to Rails 5.0 --- source 'https://rubygems.org' @@ -77,7 +82,7 @@ gem 'validates_hostname', '~> 1.0.6' gem 'browser', '~> 2.5' # GPG -gem 'gpgme' +gem 'gpgme', '~> 2.0.18' # LDAP Auth # GitLab fork with several improvements to original library. For full list of changes @@ -86,7 +91,7 @@ gem 'gitlab_omniauth-ldap', '~> 2.0.4', require: 'omniauth-ldap' gem 'net-ldap' # API -gem 'grape', '~> 1.1' +gem 'grape', '~> 1.1.0' gem 'grape-entity', '~> 0.7.1' gem 'rack-cors', '~> 1.0.0', require: 'rack/cors' @@ -154,6 +159,8 @@ gem 'icalendar' gem 'diffy', '~> 3.1.0' # Application server +gem 'rack', gem_versions['rack'] + group :unicorn do gem 'unicorn', '~> 5.1.0' gem 'unicorn-worker-killer', '~> 0.4.4' @@ -256,6 +263,9 @@ gem 'ace-rails-ap', '~> 4.1.0' # Detect and convert string character encoding gem 'charlock_holmes', '~> 0.7.5' +# Detect mime content type from content +gem 'mimemagic', '~> 0.3.2' + # Faster blank gem 'fast_blank' @@ -291,7 +301,7 @@ gem 'gettext_i18n_rails', '~> 1.8.0' gem 'gettext_i18n_rails_js', '~> 1.3' gem 'gettext', '~> 3.2.2', require: false, group: :development -gem 'batch-loader', '~> 1.2.1' +gem 'batch-loader', '~> 1.2.2' # Perf bar gem 'peek', '~> 1.0.1' @@ -425,7 +435,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 1.1.0', require: 'gitaly' +gem 'gitaly-proto', '~> 1.3.0', require: 'gitaly' gem 'grpc', '~> 1.15.0' gem 'google-protobuf', '~> 3.6' diff --git a/Gemfile.lock b/Gemfile.lock index f622c6292b2..608d1814127 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -73,7 +73,7 @@ GEM thread_safe (~> 0.3, >= 0.3.1) babosa (1.0.2) base32 (0.3.2) - batch-loader (1.2.1) + batch-loader (1.2.2) bcrypt (3.1.12) bcrypt_pbkdf (1.0.0) benchmark-ips (2.3.0) @@ -82,6 +82,7 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) bindata (2.4.3) + binding_ninja (0.2.2) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) bootsnap (1.3.2) @@ -273,7 +274,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (1.1.0) + gitaly-proto (1.3.0) grpc (~> 1.0) github-markup (1.7.0) gitlab-default_value_for (3.1.1) @@ -313,8 +314,8 @@ GEM multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (~> 0.7) - gpgme (2.0.13) - mini_portile2 (~> 2.1) + gpgme (2.0.18) + mini_portile2 (~> 2.3) grape (1.1.0) activesupport builder @@ -458,7 +459,7 @@ GEM mime-types (3.2.2) mime-types-data (~> 3.2015) mime-types-data (3.2018.0812) - mimemagic (0.3.0) + mimemagic (0.3.2) mini_magick (4.8.0) mini_mime (1.0.1) mini_portile2 (2.3.0) @@ -724,8 +725,8 @@ GEM rspec-mocks (3.7.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.7.0) - rspec-parameterized (0.4.0) - binding_of_caller + rspec-parameterized (0.4.1) + binding_ninja (>= 0.2.1) parser proc_to_ast rspec (>= 2.13, < 4) @@ -895,7 +896,7 @@ GEM get_process_mem (~> 0) unicorn (>= 4, < 6) uniform_notifier (1.10.0) - unparser (0.2.7) + unparser (0.4.2) abstract_type (~> 0.0.7) adamantium (~> 0.2.0) concord (~> 0.1.5) @@ -950,7 +951,7 @@ DEPENDENCIES awesome_print babosa (~> 1.0.2) base32 (~> 0.3.0) - batch-loader (~> 1.2.1) + batch-loader (~> 1.2.2) bcrypt_pbkdf (~> 1.0) benchmark-ips (~> 2.3.0) better_errors (~> 2.5.0) @@ -1006,7 +1007,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 1.1.0) + gitaly-proto (~> 1.3.0) github-markup (~> 1.7.0) gitlab-default_value_for (~> 3.1.1) gitlab-markup (~> 1.6.5) @@ -1016,8 +1017,8 @@ DEPENDENCIES gon (~> 6.2) google-api-client (~> 0.23) google-protobuf (~> 3.6) - gpgme - grape (~> 1.1) + gpgme (~> 2.0.18) + grape (~> 1.1.0) grape-entity (~> 0.7.1) grape-path-helpers (~> 1.0) grape_logging (~> 1.7) @@ -1050,6 +1051,7 @@ DEPENDENCIES loofah (~> 2.2) mail_room (~> 0.9.1) method_source (~> 0.8) + mimemagic (~> 0.3.2) mini_magick minitest (~> 5.7.0) mysql2 (~> 0.4.10) @@ -1088,6 +1090,7 @@ DEPENDENCIES pry-rails (~> 0.3.4) puma (~> 3.12) puma_worker_killer + rack (= 2.0.6) rack-attack (~> 4.4.1) rack-cors (~> 1.0.0) rack-oauth2 (~> 1.2.1) diff --git a/Gemfile.rails4.lock b/Gemfile.rails4.lock index 2542e085815..9e7bae84299 100644 --- a/Gemfile.rails4.lock +++ b/Gemfile.rails4.lock @@ -70,7 +70,7 @@ GEM thread_safe (~> 0.3, >= 0.3.1) babosa (1.0.2) base32 (0.3.2) - batch-loader (1.2.1) + batch-loader (1.2.2) bcrypt (3.1.12) bcrypt_pbkdf (1.0.0) benchmark-ips (2.3.0) @@ -79,6 +79,7 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) bindata (2.4.3) + binding_ninja (0.2.2) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) bootsnap (1.3.2) @@ -272,7 +273,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (1.1.0) + gitaly-proto (1.3.0) grpc (~> 1.0) github-markup (1.7.0) gitlab-markup (1.6.5) @@ -310,8 +311,8 @@ GEM multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (~> 0.7) - gpgme (2.0.13) - mini_portile2 (~> 2.1) + gpgme (2.0.18) + mini_portile2 (~> 2.3) grape (1.1.0) activesupport builder @@ -455,7 +456,7 @@ GEM mime-types (3.2.2) mime-types-data (~> 3.2015) mime-types-data (3.2018.0812) - mimemagic (0.3.0) + mimemagic (0.3.2) mini_magick (4.8.0) mini_mime (1.0.1) mini_portile2 (2.3.0) @@ -618,16 +619,16 @@ GEM rack rack-test (0.6.3) rack (>= 1.0) - rails (4.2.10) - actionmailer (= 4.2.10) - actionpack (= 4.2.10) - actionview (= 4.2.10) - activejob (= 4.2.10) - activemodel (= 4.2.10) - activerecord (= 4.2.10) - activesupport (= 4.2.10) + rails (4.2.11) + actionmailer (= 4.2.11) + actionpack (= 4.2.11) + actionview (= 4.2.11) + activejob (= 4.2.11) + activemodel (= 4.2.11) + activerecord (= 4.2.11) + activesupport (= 4.2.11) bundler (>= 1.3.0, < 2.0) - railties (= 4.2.10) + railties (= 4.2.11) sprockets-rails rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) @@ -715,8 +716,8 @@ GEM rspec-mocks (3.7.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.7.0) - rspec-parameterized (0.4.0) - binding_of_caller + rspec-parameterized (0.4.1) + binding_ninja (>= 0.2.1) parser proc_to_ast rspec (>= 2.13, < 4) @@ -889,7 +890,7 @@ GEM get_process_mem (~> 0) unicorn (>= 4, < 6) uniform_notifier (1.10.0) - unparser (0.2.7) + unparser (0.4.2) abstract_type (~> 0.0.7) adamantium (~> 0.2.0) concord (~> 0.1.5) @@ -941,7 +942,7 @@ DEPENDENCIES awesome_print babosa (~> 1.0.2) base32 (~> 0.3.0) - batch-loader (~> 1.2.1) + batch-loader (~> 1.2.2) bcrypt_pbkdf (~> 1.0) benchmark-ips (~> 2.3.0) better_errors (~> 2.5.0) @@ -998,7 +999,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 1.1.0) + gitaly-proto (~> 1.3.0) github-markup (~> 1.7.0) gitlab-markup (~> 1.6.5) gitlab-sidekiq-fetcher @@ -1007,8 +1008,8 @@ DEPENDENCIES gon (~> 6.2) google-api-client (~> 0.23) google-protobuf (~> 3.6) - gpgme - grape (~> 1.1) + gpgme (~> 2.0.18) + grape (~> 1.1.0) grape-entity (~> 0.7.1) grape-path-helpers (~> 1.0) grape_logging (~> 1.7) @@ -1041,6 +1042,7 @@ DEPENDENCIES loofah (~> 2.2) mail_room (~> 0.9.1) method_source (~> 0.8) + mimemagic (~> 0.3.2) mini_magick minitest (~> 5.7.0) mysql2 (~> 0.4.10) @@ -1079,11 +1081,12 @@ DEPENDENCIES pry-rails (~> 0.3.4) puma (~> 3.12) puma_worker_killer + rack (= 1.6.11) rack-attack (~> 4.4.1) rack-cors (~> 1.0.0) rack-oauth2 (~> 1.2.1) rack-proxy (~> 0.6.0) - rails (= 4.2.10) + rails (= 4.2.11) rails-deprecated_sanitizer (~> 1.0.3) rails-i18n (~> 4.0.9) rainbow (~> 3.0) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 3f7a1ef1bfc..de003e70e61 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,15 +5,17 @@ import axios from './lib/utils/axios_utils'; const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', + subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', projectLabelsPath: '/:namespace_path/:project_path/labels', - mergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', + projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', + projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', + projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', + projectRunnersPath: '/api/:version/projects/:id/runners', mergeRequestsPath: '/api/:version/merge_requests', - mergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', - mergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', groupLabelsPath: '/groups/:namespace_path/-/labels', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', @@ -99,36 +101,45 @@ const Api = { }, // Return Merge Request for project - mergeRequest(projectPath, mergeRequestId, params = {}) { - const url = Api.buildUrl(Api.mergeRequestPath) + projectMergeRequest(projectPath, mergeRequestId, params = {}) { + const url = Api.buildUrl(Api.projectMergeRequestPath) .replace(':id', encodeURIComponent(projectPath)) .replace(':mrid', mergeRequestId); return axios.get(url, { params }); }, - mergeRequests(params = {}) { - const url = Api.buildUrl(Api.mergeRequestsPath); - - return axios.get(url, { params }); - }, - - mergeRequestChanges(projectPath, mergeRequestId) { - const url = Api.buildUrl(Api.mergeRequestChangesPath) + projectMergeRequestChanges(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.projectMergeRequestChangesPath) .replace(':id', encodeURIComponent(projectPath)) .replace(':mrid', mergeRequestId); return axios.get(url); }, - mergeRequestVersions(projectPath, mergeRequestId) { - const url = Api.buildUrl(Api.mergeRequestVersionsPath) + projectMergeRequestVersions(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.projectMergeRequestVersionsPath) .replace(':id', encodeURIComponent(projectPath)) .replace(':mrid', mergeRequestId); return axios.get(url); }, + projectRunners(projectPath, config = {}) { + const url = Api.buildUrl(Api.projectRunnersPath).replace( + ':id', + encodeURIComponent(projectPath), + ); + + return axios.get(url, config); + }, + + mergeRequests(params = {}) { + const url = Api.buildUrl(Api.mergeRequestsPath); + + return axios.get(url, { params }); + }, + newLabel(namespacePath, projectPath, data, callback) { let url; diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 720f30e18e6..35380ca49fb 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -26,6 +26,9 @@ export default function renderMermaid($els) { }, // mermaidAPI options theme: 'neutral', + flowchart: { + htmlLabels: false, + }, }); $els.each((i, el) => { diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index c09d9ccddd6..d8056e48d4e 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -50,10 +50,11 @@ function hideOrShowHelpBlock(form) { } $(() => { - const $form = $('form.js-requires-input'); - if ($form) { + $('form.js-requires-input').each((i, el) => { + const $form = $(el); + $form.requiresInput(); hideOrShowHelpBlock($form); $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form)); - } + }); }); diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 31651658fe6..d899b7fbd8c 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -92,20 +92,7 @@ export default { {{ selectedProjectName }} <icon name="chevron-down" /> </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"> - <div class="dropdown-title"> - <span>Projects</span> - <button - aria-label="Close" - type="button" - class="dropdown-title-button dropdown-menu-close" - > - <icon - name="merge-request-close-m" - data-hidden="true" - class="dropdown-menu-close-icon" - /> - </button> - </div> + <div class="dropdown-title">Projects</div> <div class="dropdown-input"> <input class="dropdown-input-field" type="search" placeholder="Search projects" /> <icon name="search" class="dropdown-input-search" data-hidden="true" /> diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index aff32d95db1..cf70a48f076 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,6 +1,6 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; -import PersistentUserCallout from '../persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; import { s__, sprintf } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; @@ -67,7 +67,7 @@ export default class Clusters { this.showTokenButton = document.querySelector('.js-show-cluster-token'); this.tokenField = document.querySelector('.js-cluster-token'); - Clusters.initDismissableCallout(); + initDismissableCallout('.js-cluster-security-warning'); initSettingsPanels(); setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area')); this.initApplications(clusterType); @@ -108,12 +108,6 @@ export default class Clusters { }); } - static initDismissableCallout() { - const callout = document.querySelector('.js-cluster-security-warning'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new - } - addListeners() { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index a37cb4def28..665a9c77822 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -296,7 +296,6 @@ export default { :request-status="applications.cert_manager.requestStatus" :request-reason="applications.cert_manager.requestReason" :disabled="!helmInstalled" - class="hide-bottom-border rounded-bottom" title-link="https://cert-manager.readthedocs.io/en/latest/#" > <div slot="description" v-html="certManagerDescription"></div> @@ -396,6 +395,7 @@ export default { </div> </application-row> <application-row + v-if="isProjectCluster" id="knative" :logo-url="knativeLogo" :title="applications.knative.title" @@ -405,17 +405,15 @@ export default { :request-reason="applications.knative.requestReason" :install-application-request-params="{ hostname: applications.knative.hostname }" :disabled="!helmInstalled" - class="hide-bottom-border rounded-bottom" title-link="https://github.com/knative/docs" > <div slot="description"> <p> {{ - s__(`ClusterIntegration|Knative (pronounced kay-nay-tiv) extends - Kubernetes to provide a set of middleware components that are - essential to build modern, source-centric, and container-based - applications that can run anywhere: on premises, in the cloud, or - even in a third-party data center.`) + s__(`ClusterIntegration|Knative extends Kubernetes to provide + a set of middleware components that are essential to build modern, + source-centric, and container-based applications that can run + anywhere: on premises, in the cloud, or even in a third-party data center.`) }} </p> @@ -433,7 +431,7 @@ export default { /> </div> </template> - <template v-else> + <template v-else-if="helmInstalled"> <div class="form-group"> <label for="knative-domainname"> {{ s__('ClusterIntegration|Knative Domain Name:') }} diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 22da38ce7a5..bf9244df7f7 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -102,6 +102,12 @@ export default { if (this.shouldShow) { this.fetchData(); } + + const id = window && window.location && window.location.hash; + + if (id) { + this.setHighlightedRow(id.slice(1)); + } }, created() { this.adjustView(); @@ -114,6 +120,7 @@ export default { 'fetchDiffFiles', 'startRenderDiffsQueue', 'assignDiscussionsToDiff', + 'setHighlightedRow', ]), fetchData() { this.fetchDiffFiles() diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index e405d8b20ae..11cc4c09fed 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -90,6 +90,8 @@ export default { :old-sha="diffFile.diff_refs.base_sha" :file-hash="diffFile.file_hash" :project-path="projectPath" + :a-mode="diffFile.a_mode" + :b-mode="diffFile.b_mode" > <image-diff-overlay slot="image-overlay" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index f7e3655ea40..3b2a0d156ca 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -52,7 +52,9 @@ export default { (!this.file.highlighted_diff_lines && !this.isLoadingCollapsedDiff && !this.file.too_large && - this.file.text) + this.file.text && + !this.file.renamed_file && + !this.file.mode_changed) ); }, showLoadingIcon() { @@ -143,9 +145,8 @@ export default { <a :href="file.fork_path" class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" + >Fork</a > - Fork - </a> <button class="js-cancel-fork-suggestion-button btn btn-grouped" type="button" @@ -163,9 +164,9 @@ export default { <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> <div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed"> {{ __('This diff is collapsed.') }} - <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle"> - {{ __('Click to expand it.') }} - </a> + <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ + __('Click to expand it.') + }}</a> </div> <div v-if="file.too_large" class="nothing-here-block diff-collapsed js-too-large-diff"> {{ __('This source diff could not be displayed because it is too large.') }} diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index b969017a2bb..0c0a0faa59d 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -56,9 +56,12 @@ export default { return `${noteData.author.name}: ${note}`; }, toggleDiscussions() { + const forceExpanded = this.discussions.some(discussion => !discussion.expanded); + this.discussions.forEach(discussion => { this.toggleDiscussion({ discussionId: discussion.id, + forceExpanded, }); }); }, diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue index c02561b7599..c0613d80d37 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -72,6 +72,13 @@ export default { diffFiles: state => state.diffs.diffFiles, }), ...mapGetters(['isLoggedIn']), + lineCode() { + return ( + this.line.line_code || + (this.line.left && this.line.line.left.line_code) || + (this.line.right && this.line.right.line_code) + ); + }, lineHref() { return `#${this.line.line_code || ''}`; }, @@ -97,9 +104,9 @@ export default { }, }, methods: { - ...mapActions('diffs', ['loadMoreLines', 'showCommentForm']), + ...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']), handleCommentButton() { - this.showCommentForm({ lineCode: this.line.line_code }); + this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); }, handleLoadMoreLines() { if (this.isRequesting) { @@ -160,7 +167,7 @@ export default { > <template v-else> <button - v-if="shouldShowCommentButton" + v-show="shouldShowCommentButton" type="button" class="add-diff-note js-add-diff-note-button qa-diff-comment" title="Add a comment to this line" @@ -168,7 +175,13 @@ export default { > <icon :size="12" name="comment" /> </button> - <a v-if="lineNumber" :data-linenumber="lineNumber" :href="lineHref"> </a> + <a + v-if="lineNumber" + :data-linenumber="lineNumber" + :href="lineHref" + @click="setHighlightedRow(lineCode);" + > + </a> <diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" /> </template> </div> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index c7cef74fe40..9fd02acbd6e 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -73,6 +73,7 @@ export default { this.cancelCommentForm({ lineCode: this.line.line_code, + fileHash: this.diffFileHash, }); this.$nextTick(() => { this.resetAutoSave(); diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index f4eb956adcb..d174b13e133 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters } from 'vuex'; +import { mapGetters, mapActions } from 'vuex'; import DiffLineGutterContent from './diff_line_gutter_content.vue'; import { MATCH_LINE_TYPE, @@ -30,6 +30,11 @@ export default { type: String, required: true, }, + isHighlighted: { + type: Boolean, + required: true, + default: false, + }, diffViewType: { type: String, required: false, @@ -85,6 +90,7 @@ export default { const { type } = this.line; return { + hll: this.isHighlighted, [type]: type, [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine, [LINE_HOVER_CLASS_NAME]: @@ -99,6 +105,7 @@ export default { return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line; }, }, + methods: mapActions('diffs', ['setHighlightedRow']), }; </script> diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue index 91b87fb042c..aa40b24950a 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -1,5 +1,4 @@ <script> -import { mapState } from 'vuex'; import diffDiscussions from './diff_discussions.vue'; import diffLineNoteForm from './diff_line_note_form.vue'; @@ -17,29 +16,31 @@ export default { type: String, required: true, }, - lineIndex: { - type: Number, - required: true, - }, }, computed: { - ...mapState({ - diffLineCommentForms: state => state.diffs.diffLineCommentForms, - }), className() { return this.line.discussions.length ? '' : 'js-temp-notes-holder'; }, + shouldRender() { + if (this.line.hasForm) return true; + + if (!this.line.discussions || !this.line.discussions.length) { + return false; + } + + return this.line.discussions.every(discussion => discussion.expanded); + }, }, }; </script> <template> - <tr :class="className" class="notes_holder"> + <tr v-if="shouldRender" :class="className" class="notes_holder"> <td class="notes_content" colspan="3"> <div class="content"> <diff-discussions v-if="line.discussions.length" :discussions="line.discussions" /> <diff-line-note-form - v-if="diffLineCommentForms[line.line_code]" + v-if="line.hasForm" :diff-file-hash="diffFileHash" :line="line" :note-target-line="line" diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index 8d53fbded73..c764cbeb8e0 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters, mapActions } from 'vuex'; +import { mapGetters, mapActions, mapState } from 'vuex'; import DiffTableCell from './diff_table_cell.vue'; import { NEW_LINE_TYPE, @@ -40,6 +40,11 @@ export default { }; }, computed: { + ...mapState({ + isHighlighted(state) { + return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow; + }, + }), ...mapGetters('diffs', ['isInlineView']), isContextLine() { return this.line.type === CONTEXT_LINE_TYPE; @@ -91,6 +96,7 @@ export default { :is-bottom="isBottom" :is-hover="isHover" :show-comment-button="true" + :is-highlighted="isHighlighted" class="diff-line-num old_line" /> <diff-table-cell @@ -100,8 +106,18 @@ export default { :line-type="newLineType" :is-bottom="isBottom" :is-hover="isHover" + :is-highlighted="isHighlighted" class="diff-line-num new_line qa-new-diff-line" /> - <td :class="line.type" class="line_content" v-html="line.rich_text"></td> + <td + :class="[ + line.type, + { + hll: isHighlighted, + }, + ]" + class="line_content" + v-html="line.rich_text" + ></td> </tr> </template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index fafc1649ce7..6a0ce760e6d 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters, mapState } from 'vuex'; +import { mapGetters } from 'vuex'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue'; @@ -19,23 +19,18 @@ export default { }, }, computed: { - ...mapGetters('diffs', ['commitId', 'shouldRenderInlineCommentRow']), - ...mapState({ - diffLineCommentForms: state => state.diffs.diffLineCommentForms, - }), + ...mapGetters('diffs', ['commitId']), diffLinesLength() { return this.diffLines.length; }, - userColorScheme() { - return window.gon.user_color_scheme; - }, }, + userColorScheme: window.gon.user_color_scheme, }; </script> <template> <table - :class="userColorScheme" + :class="$options.userColorScheme" :data-commit-id="commitId" class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view" > @@ -49,11 +44,9 @@ export default { :is-bottom="index + 1 === diffLinesLength" /> <inline-diff-comment-row - v-if="shouldRenderInlineCommentRow(line)" - :key="index" + :key="`icr-${index}`" :diff-file-hash="diffFile.file_hash" :line="line" - :line-index="index" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index c6b50983277..b98463d3dd3 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -1,5 +1,4 @@ <script> -import { mapState } from 'vuex'; import diffDiscussions from './diff_discussions.vue'; import diffLineNoteForm from './diff_line_note_form.vue'; @@ -23,22 +22,13 @@ export default { }, }, computed: { - ...mapState({ - diffLineCommentForms: state => state.diffs.diffLineCommentForms, - }), - leftLineCode() { - return this.line.left && this.line.left.line_code; - }, - rightLineCode() { - return this.line.right && this.line.right.line_code; - }, hasExpandedDiscussionOnLeft() { - return this.line.left && this.line.left.discussions + return this.line.left && this.line.left.discussions.length ? this.line.left.discussions.every(discussion => discussion.expanded) : false; }, hasExpandedDiscussionOnRight() { - return this.line.right && this.line.right.discussions + return this.line.right && this.line.right.discussions.length ? this.line.right.discussions.every(discussion => discussion.expanded) : false; }, @@ -57,9 +47,10 @@ export default { ); }, showRightSideCommentForm() { - return ( - this.line.right && this.line.right.type && this.diffLineCommentForms[this.rightLineCode] - ); + return this.line.right && this.line.right.type && this.line.right.hasForm; + }, + showLeftSideCommentForm() { + return this.line.left && this.line.left.hasForm; }, className() { return (this.left && this.line.left.discussions.length > 0) || @@ -67,12 +58,30 @@ export default { ? '' : 'js-temp-notes-holder'; }, + shouldRender() { + const { line } = this; + const hasDiscussion = + (line.left && line.left.discussions && line.left.discussions.length) || + (line.right && line.right.discussions && line.right.discussions.length); + + if ( + hasDiscussion && + (this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight) + ) { + return true; + } + + const hasCommentFormOnLeft = line.left && line.left.hasForm; + const hasCommentFormOnRight = line.right && line.right.hasForm; + + return hasCommentFormOnLeft || hasCommentFormOnRight; + }, }, }; </script> <template> - <tr :class="className" class="notes_holder"> + <tr v-if="shouldRender" :class="className" class="notes_holder"> <td class="notes_content parallel old" colspan="2"> <div v-if="shouldRenderDiscussionsOnLeft" class="content"> <diff-discussions @@ -81,7 +90,7 @@ export default { /> </div> <diff-line-note-form - v-if="diffLineCommentForms[leftLineCode]" + v-if="showLeftSideCommentForm" :diff-file-hash="diffFileHash" :line="line.left" :note-target-line="line.left" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index 248dfd9815e..caf0df8a4e3 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -1,5 +1,5 @@ <script> -import { mapActions } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import $ from 'jquery'; import DiffTableCell from './diff_table_cell.vue'; import { @@ -43,6 +43,15 @@ export default { }; }, computed: { + ...mapState({ + isHighlighted(state) { + const lineCode = + (this.line.left && this.line.left.line_code) || + (this.line.right && this.line.right.line_code); + + return lineCode ? lineCode === state.diffs.highlightedRow : false; + }, + }), isContextLine() { return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE; }, @@ -57,7 +66,14 @@ export default { return OLD_NO_NEW_LINE_TYPE; } - return this.line.left ? this.line.left.type : EMPTY_CELL_TYPE; + const lineTypeClass = this.line.left ? this.line.left.type : EMPTY_CELL_TYPE; + + return [ + lineTypeClass, + { + hll: this.isHighlighted, + }, + ]; }, }, created() { @@ -114,6 +130,7 @@ export default { :line-type="oldLineType" :is-bottom="isBottom" :is-hover="isLeftHover" + :is-highlighted="isHighlighted" :show-comment-button="true" :diff-view-type="parallelDiffViewType" line-position="left" @@ -139,6 +156,7 @@ export default { :line-type="newLineType" :is-bottom="isBottom" :is-hover="isRightHover" + :is-highlighted="isHighlighted" :show-comment-button="true" :diff-view-type="parallelDiffViewType" line-position="right" @@ -146,7 +164,12 @@ export default { /> <td :id="line.right.line_code" - :class="line.right.type" + :class="[ + line.right.type, + { + hll: isHighlighted, + }, + ]" class="line_content parallel right-side" @mousedown.native="handleParallelLineMouseDown" v-html="line.right.rich_text" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 771b8a80352..9a6e0e82529 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -1,5 +1,5 @@ <script> -import { mapState, mapGetters } from 'vuex'; +import { mapGetters } from 'vuex'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; @@ -19,23 +19,18 @@ export default { }, }, computed: { - ...mapGetters('diffs', ['commitId', 'shouldRenderParallelCommentRow']), - ...mapState({ - diffLineCommentForms: state => state.diffs.diffLineCommentForms, - }), + ...mapGetters('diffs', ['commitId']), diffLinesLength() { return this.diffLines.length; }, - userColorScheme() { - return window.gon.user_color_scheme; - }, }, + userColorScheme: window.gon.user_color_scheme, }; </script> <template> <div - :class="userColorScheme" + :class="$options.userColorScheme" :data-commit-id="commitId" class="code diff-wrap-lines js-syntax-highlight text-file" > @@ -50,7 +45,6 @@ export default { :is-bottom="index + 1 === diffLinesLength" /> <parallel-diff-comment-row - v-if="shouldRenderParallelCommentRow(line)" :key="`dcr-${index}`" :line="line" :diff-file-hash="diffFile.file_hash" diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index a3de058b20e..952963e0711 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -33,6 +33,10 @@ export const fetchDiffFiles = ({ state, commit }) => { .then(handleLocationHash); }; +export const setHighlightedRow = ({ commit }, lineCode) => { + commit(types.SET_HIGHLIGHTED_ROW, lineCode); +}; + // This is adding line discussions to the actual lines in the diff tree // once for parallel and once for inline mode export const assignDiscussionsToDiff = ( @@ -99,12 +103,12 @@ export const setParallelDiffViewType = ({ commit }) => { historyPushState(url); }; -export const showCommentForm = ({ commit }, params) => { - commit(types.ADD_COMMENT_FORM_LINE, params); +export const showCommentForm = ({ commit }, { lineCode, fileHash }) => { + commit(types.TOGGLE_LINE_HAS_FORM, { lineCode, fileHash, hasForm: true }); }; -export const cancelCommentForm = ({ commit }, params) => { - commit(types.REMOVE_COMMENT_FORM_LINE, params); +export const cancelCommentForm = ({ commit }, { lineCode, fileHash }) => { + commit(types.TOGGLE_LINE_HAS_FORM, { lineCode, fileHash, hasForm: false }); }; export const loadMoreLines = ({ commit }, options) => { @@ -127,7 +131,7 @@ export const loadMoreLines = ({ commit }, options) => { export const scrollToLineIfNeededInline = (_, line) => { const hash = getLocationHash(); - if (hash && line.lineCode === hash) { + if (hash && line.line_code === hash) { handleLocationHash(); } }; @@ -137,19 +141,25 @@ export const scrollToLineIfNeededParallel = (_, line) => { if ( hash && - ((line.left && line.left.lineCode === hash) || (line.right && line.right.lineCode === hash)) + ((line.left && line.left.line_code === hash) || (line.right && line.right.line_code === hash)) ) { handleLocationHash(); } }; -export const loadCollapsedDiff = ({ commit }, file) => - axios.get(file.loadCollapsedDiffUrl).then(res => { - commit(types.ADD_COLLAPSED_DIFFS, { - file, - data: res.data, +export const loadCollapsedDiff = ({ commit, getters }, file) => + axios + .get(file.load_collapsed_diff_url, { + params: { + commit_id: getters.commitId, + }, + }) + .then(res => { + commit(types.ADD_COLLAPSED_DIFFS, { + file, + data: res.data, + }); }); - }); export const expandAllFiles = ({ commit }) => { commit(types.EXPAND_ALL_FILES); @@ -182,8 +192,9 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => { }); }; -export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => { +export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { const postData = getNoteFormData({ + commit: state.commit, note, ...formData, }); @@ -191,8 +202,8 @@ export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => { return dispatch('saveNote', postData, { root: true }) .then(result => dispatch('updateDiscussion', result.discussion, { root: true })) .then(discussion => dispatch('assignDiscussionsToDiff', [discussion])) + .then(() => dispatch('updateResolvableDiscussonsCounts', null, { root: true })) .then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash)) - .then(() => dispatch('startTaskList', null, { root: true })) .catch(() => createFlash(s__('MergeRequests|Saving the comment failed'))); }; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 6a87b712b48..fdf1efbb10e 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -70,40 +70,6 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = discussion => discussion.diff_discussion && discussion.diff_file.file_hash === diff.file_hash, ) || []; -export const shouldRenderParallelCommentRow = state => line => { - const hasDiscussion = - (line.left && line.left.discussions && line.left.discussions.length) || - (line.right && line.right.discussions && line.right.discussions.length); - - const hasExpandedDiscussionOnLeft = - line.left && line.left.discussions && line.left.discussions.length - ? line.left.discussions.every(discussion => discussion.expanded) - : false; - const hasExpandedDiscussionOnRight = - line.right && line.right.discussions && line.right.discussions.length - ? line.right.discussions.every(discussion => discussion.expanded) - : false; - - if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) { - return true; - } - - const hasCommentFormOnLeft = line.left && state.diffLineCommentForms[line.left.line_code]; - const hasCommentFormOnRight = line.right && state.diffLineCommentForms[line.right.line_code]; - - return hasCommentFormOnLeft || hasCommentFormOnRight; -}; - -export const shouldRenderInlineCommentRow = state => line => { - if (state.diffLineCommentForms[line.line_code]) return true; - - if (!line.discussions || line.discussions.length === 0) { - return false; - } - - return line.discussions.every(discussion => discussion.expanded); -}; - // prevent babel-plugin-rewire from generating an invalid default during karma∂ tests export const getDiffFileByHash = state => fileHash => state.diffFiles.find(file => file.file_hash === fileHash); diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 2f59a3822f4..98e57d52d77 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -18,7 +18,6 @@ export default () => ({ diffFiles: [], mergeRequestDiffs: [], mergeRequestDiff: null, - diffLineCommentForms: {}, diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, tree: [], treeEntries: {}, @@ -27,4 +26,5 @@ export default () => ({ currentDiffFileId: '', projectPath: '', commentForms: [], + highlightedRow: null, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index e011031e72c..0338cde3658 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -3,8 +3,7 @@ export const SET_LOADING = 'SET_LOADING'; export const SET_DIFF_DATA = 'SET_DIFF_DATA'; export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE'; export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS'; -export const ADD_COMMENT_FORM_LINE = 'ADD_COMMENT_FORM_LINE'; -export const REMOVE_COMMENT_FORM_LINE = 'REMOVE_COMMENT_FORM_LINE'; +export const TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM'; export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES'; export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS'; export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES'; @@ -18,3 +17,4 @@ export const UPDATE_CURRENT_DIFF_FILE_ID = 'UPDATE_CURRENT_DIFF_FILE_ID'; export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM'; export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM'; export const CLOSE_DIFF_FILE_COMMENT_FORM = 'CLOSE_DIFF_FILE_COMMENT_FORM'; +export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 2133cfe4825..331fb052371 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -1,4 +1,3 @@ -import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { sortTree } from '~/ide/stores/utils'; import { @@ -49,12 +48,30 @@ export default { Object.assign(state, { diffViewType }); }, - [types.ADD_COMMENT_FORM_LINE](state, { lineCode }) { - Vue.set(state.diffLineCommentForms, lineCode, true); - }, + [types.TOGGLE_LINE_HAS_FORM](state, { lineCode, fileHash, hasForm }) { + const diffFile = state.diffFiles.find(f => f.file_hash === fileHash); + + if (!diffFile) return; + + if (diffFile.highlighted_diff_lines) { + diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm; + } + + if (diffFile.parallel_diff_lines) { + const line = diffFile.parallel_diff_lines.find(l => { + const { left, right } = l; + + return (left && left.line_code === lineCode) || (right && right.line_code === lineCode); + }); + + if (line.left && line.left.line_code === lineCode) { + line.left.hasForm = hasForm; + } - [types.REMOVE_COMMENT_FORM_LINE](state, { lineCode }) { - Vue.delete(state.diffLineCommentForms, lineCode); + if (line.right && line.right.line_code === lineCode) { + line.right.hasForm = hasForm; + } + } }, [types.ADD_CONTEXT_LINES](state, options) { @@ -68,6 +85,7 @@ export default { ...line, line_code: line.line_code || `${fileHash}_${line.old_line}_${line.new_line}`, discussions: line.discussions || [], + hasForm: false, })); addContextLines({ @@ -112,7 +130,7 @@ export default { if (file.highlighted_diff_lines) { file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => { - if (lineCheck(line)) { + if (!line.discussions.some(({ id }) => discussion.id === id) && lineCheck(line)) { return { ...line, discussions: line.discussions.concat(discussion), @@ -132,11 +150,17 @@ export default { return { left: { ...line.left, - discussions: left ? line.left.discussions.concat(discussion) : [], + discussions: + left && !line.left.discussions.some(({ id }) => id === discussion.id) + ? line.left.discussions.concat(discussion) + : (line.left && line.left.discussions) || [], }, right: { ...line.right, - discussions: right && !left ? line.right.discussions.concat(discussion) : [], + discussions: + right && !left && !line.right.discussions.some(({ id }) => id === discussion.id) + ? line.right.discussions.concat(discussion) + : (line.right && line.right.discussions) || [], }, }; } @@ -223,4 +247,7 @@ export default { [types.CLOSE_DIFF_FILE_COMMENT_FORM](state, fileHash) { state.commentForms = state.commentForms.filter(form => form.fileHash !== fileHash); }, + [types.SET_HIGHLIGHTED_ROW](state, lineCode) { + state.highlightedRow = lineCode; + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index d9d3c0f2ca2..cbaa0e26395 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -27,6 +27,7 @@ export const getReversePosition = linePosition => { export function getFormData(params) { const { + commit, note, noteableType, noteableData, @@ -66,7 +67,7 @@ export function getFormData(params) { position, noteable_type: noteableType, noteable_id: noteableData.id, - commit_id: '', + commit_id: commit && commit.id, type: diffFile.diff_refs.start_sha && diffFile.diff_refs.head_sha ? DIFF_NOTE_TYPE @@ -209,9 +210,11 @@ export function prepareDiffData(diffData) { const line = file.parallel_diff_lines[u]; if (line.left) { line.left = trimFirstCharOfLineContent(line.left); + line.left.hasForm = false; } if (line.right) { line.right = trimFirstCharOfLineContent(line.right); + line.right.hasForm = false; } } } @@ -220,7 +223,7 @@ export function prepareDiffData(diffData) { const linesLength = file.highlighted_diff_lines.length; for (let u = 0; u < linesLength; u += 1) { const line = file.highlighted_diff_lines[u]; - Object.assign(line, { ...trimFirstCharOfLineContent(line) }); + Object.assign(line, { ...trimFirstCharOfLineContent(line), hasForm: false }); } showingLines += file.parallel_diff_lines.length; } @@ -322,5 +325,9 @@ export const generateTreeList = files => export const getDiffMode = diffFile => { const diffModeKey = Object.keys(diffModes).find(key => diffFile[`${key}_file`]); - return diffModes[diffModeKey] || diffModes.replaced; + return ( + diffModes[diffModeKey] || + (diffFile.mode_changed && diffModes.mode_changed) || + diffModes.replaced + ); }; diff --git a/app/assets/javascripts/dismissable_callout.js b/app/assets/javascripts/dismissable_callout.js new file mode 100644 index 00000000000..5185b019376 --- /dev/null +++ b/app/assets/javascripts/dismissable_callout.js @@ -0,0 +1,27 @@ +import $ from 'jquery'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import Flash from '~/flash'; + +export default function initDismissableCallout(alertSelector) { + const alertEl = document.querySelector(alertSelector); + if (!alertEl) { + return; + } + + const closeButtonEl = alertEl.getElementsByClassName('close')[0]; + const { dismissEndpoint, featureId } = closeButtonEl.dataset; + + closeButtonEl.addEventListener('click', () => { + axios + .post(dismissEndpoint, { + feature_name: featureId, + }) + .then(() => { + $(alertEl).alert('close'); + }) + .catch(() => { + Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); + }); + }); +} diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 5164d87c5fa..533e90e2222 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -70,7 +70,7 @@ export default { <template v-if="shouldRenderFolderContent(model)"> <div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`"> - <gl-loading-icon :size="2" /> + <gl-loading-icon :size="2" class="prepend-top-16" /> </div> <template v-else> diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index bb0ecb8efe7..b494b7e2de0 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -88,11 +88,16 @@ export const conditions = [ value: 'started', }, { - url: 'label_name[]=No+Label', + url: 'label_name[]=None', tokenKey: 'label', value: 'none', }, { + url: 'label_name[]=Any', + tokenKey: 'any', + value: 'any', + }, + { url: 'my_reaction_emoji=None', tokenKey: 'my-reaction', value: 'none', diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index b4a3037c1b7..2049760fe29 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -10,13 +10,18 @@ export default function groupsSelect() { const $select = $(this); const allAvailable = $select.data('allAvailable'); const skipGroups = $select.data('skipGroups') || []; + const parentGroupID = $select.data('parentId'); + const groupsPath = parentGroupID + ? Api.subgroupsPath.replace(':id', parentGroupID) + : Api.groupsPath; + $select.select2({ placeholder: 'Search for a group', allowClear: $select.hasClass('allowClear'), multiple: $select.hasClass('multiselect'), minimumInputLength: 0, ajax: { - url: Api.buildUrl(Api.groupsPath), + url: Api.buildUrl(groupsPath), dataType: 'json', quietMillis: 250, transport(params) { diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index e318367a5ec..7a57ccf2dd3 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -105,7 +105,7 @@ export default { :key="tabView.name" class="h-100" > - <component :is="tabView.name" /> + <component :is="tabView.component || tabView.name" /> </div> </resizable-panel> <nav class="ide-activity-bar"> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 3b201f006aa..09245ed0296 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -26,6 +26,7 @@ export const diffModes = { new: 'new', deleted: 'deleted', renamed: 'renamed', + mode_changed: 'mode_changed', }; export const rightSidebarViews = { diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index fbf944499d5..6351948f750 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { mapActions } from 'vuex'; +import _ from 'underscore'; import Translate from '~/vue_shared/translate'; import ide from './components/ide.vue'; import store from './stores'; @@ -13,19 +14,19 @@ Vue.use(Translate); * * @param {Element} el - The element that will contain the IDE. * @param {Object} options - Extra options for the IDE (Used by EE). - * @param {(e:Element) => Object} options.extraInitialData - - * Function that returns extra properties to seed initial data. * @param {Component} options.rootComponent - * Component that overrides the root component. + * @param {(store:Vuex.Store, el:Element) => Vuex.Store} options.extendStore - + * Function that receives the default store and returns an extended one. */ export function initIde(el, options = {}) { if (!el) return null; - const { extraInitialData = () => ({}), rootComponent = ide } = options; + const { rootComponent = ide, extendStore = _.identity } = options; return new Vue({ el, - store, + store: extendStore(store, el), router, created() { this.setEmptyStateSvgs({ @@ -41,7 +42,6 @@ export function initIde(el, options = {}) { }); this.setInitialData({ clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled), - ...extraInitialData(el), }); }, methods: { diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index f0193d8e8ea..13449592e62 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -41,13 +41,13 @@ export default { return Api.project(`${namespace}/${project}`); }, getProjectMergeRequestData(projectId, mergeRequestId, params = {}) { - return Api.mergeRequest(projectId, mergeRequestId, params); + return Api.projectMergeRequest(projectId, mergeRequestId, params); }, getProjectMergeRequestChanges(projectId, mergeRequestId) { - return Api.mergeRequestChanges(projectId, mergeRequestId); + return Api.projectMergeRequestChanges(projectId, mergeRequestId); }, getProjectMergeRequestVersions(projectId, mergeRequestId) { - return Api.mergeRequestVersions(projectId, mergeRequestId); + return Api.projectMergeRequestVersions(projectId, mergeRequestId); }, getBranchData(projectId, currentBranchId) { return Api.branchSingle(projectId, currentBranchId); diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js index 4565c11a83f..8b5f7558654 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js @@ -23,13 +23,19 @@ export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search } export const receiveMergeRequestsSuccess = ({ commit }, data) => commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, data); -export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, search = '' }) => { +export const fetchMergeRequests = ( + { dispatch, state: { state }, rootState: { currentProjectId } }, + { type, search = '' }, +) => { dispatch('requestMergeRequests'); dispatch('resetMergeRequests'); - const scope = type ? scopes[type] : 'all'; + const scope = type && scopes[type]; + const request = scope + ? Api.mergeRequests({ scope, state, search }) + : Api.projectMergeRequest(currentProjectId, '', { state, search }); - return Api.mergeRequests({ scope, state, search }) + return request .then(({ data }) => dispatch('receiveMergeRequestsSuccess', data)) .catch(() => dispatch('receiveMergeRequestsError', { type, search })); }; diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index 2d09cf5760f..f7fbb9503a0 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -128,7 +128,7 @@ export default { }; </script> <template> - <div class="prepend-top-default js-environment-container"> + <div class="prepend-top-default append-bottom-default js-environment-container"> <div class="environment-information"> <ci-icon :status="iconStatus" /> <p class="inline append-bottom-0" v-html="environment"></p> diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index 7b077d5e621..ec52d272168 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -28,20 +28,22 @@ export default { <div class="bs-callout bs-callout-warning"> <p v-if="tags.length" class="js-stuck-with-tags append-bottom-0"> {{ - s__(`This job is stuck, because you don't have + s__(`This job is stuck because you don't have any active runners online with any of these tags assigned to them:`) }} - <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary"> {{ tag }} </span> + <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4"> + {{ tag }} + </span> </p> <p v-else-if="hasNoRunnersForProject" class="js-stuck-no-runners append-bottom-0"> {{ - s__(`Job|This job is stuck, because the project + s__(`Job|This job is stuck because the project doesn't have any runners online assigned to it.`) }} </p> <p v-else class="js-stuck-no-active-runner append-bottom-0"> {{ - s__(`This job is stuck, because you don't + s__(`This job is stuck because you don't have any active runners that can run this job.`) }} </p> diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 6f42382246d..7933c234384 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -7,3 +7,8 @@ export const addClassIfElementExists = (element, className) => { }; export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isInMRPage(); + +export const canScrollUp = ({ scrollTop }, margin = 0) => scrollTop > margin; + +export const canScrollDown = ({ scrollTop, offsetHeight, scrollHeight }, margin = 0) => + scrollTop + offsetHeight < scrollHeight - margin; diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js new file mode 100644 index 00000000000..b41ffb44971 --- /dev/null +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -0,0 +1,13 @@ +export default (buttonSelector, fileSelector) => { + const btn = document.querySelector(buttonSelector); + const fileInput = document.querySelector(fileSelector); + const form = btn.closest('form'); + + btn.addEventListener('click', () => { + fileInput.click(); + }); + + fileInput.addEventListener('change', () => { + form.querySelector('.js-filename').textContent = fileInput.value.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape + }); +}; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index e4852c85378..14c02218990 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -16,7 +16,9 @@ const httpStatusCodes = { IM_USED: 226, MULTIPLE_CHOICES: 300, BAD_REQUEST: 400, + FORBIDDEN: 403, NOT_FOUND: 404, + UNPROCESSABLE_ENTITY: 422, }; export const successCodes = [ diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index a282c2df441..9850f7ce782 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -17,27 +17,29 @@ export function getParameterValues(sParam) { // @param {Object} params - url keys and value to merge // @param {String} url export function mergeUrlParams(params, url) { - let newUrl = Object.keys(params).reduce((acc, paramName) => { - const paramValue = encodeURIComponent(params[paramName]); - const pattern = new RegExp(`\\b(${paramName}=).*?(&|$)`); - - if (paramValue === null) { - return acc.replace(pattern, ''); - } else if (url.search(pattern) !== -1) { - return acc.replace(pattern, `$1${paramValue}$2`); - } - - return `${acc}${acc.indexOf('?') > 0 ? '&' : '?'}${paramName}=${paramValue}`; - }, decodeURIComponent(url)); + const re = /^([^?#]*)(\?[^#]*)?(.*)/; + const merged = {}; + const urlparts = url.match(re); + + if (urlparts[2]) { + urlparts[2] + .substr(1) + .split('&') + .forEach(part => { + if (part.length) { + const kv = part.split('='); + merged[decodeURIComponent(kv[0])] = decodeURIComponent(kv.slice(1).join('=')); + } + }); + } - // Remove a trailing ampersand - const lastChar = newUrl[newUrl.length - 1]; + Object.assign(merged, params); - if (lastChar === '&') { - newUrl = newUrl.slice(0, -1); - } + const query = Object.keys(merged) + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(merged[key])}`) + .join('&'); - return newUrl; + return `${urlparts[1]}?${query}${urlparts[3]}`; } export function removeParamQueryString(url, param) { diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index d32f39881dd..75c18a9b6a0 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -155,7 +155,7 @@ export default class MilestoneSelect { const { $el, e } = clickEvent; let selected = clickEvent.selectedObj; - let data, boardsStore; + let data, modalStoreFilter; if (!selected) return; if (options.handleClick) { @@ -179,11 +179,11 @@ export default class MilestoneSelect { } if ($dropdown.closest('.add-issues-modal').length) { - boardsStore = ModalStore.store.filter; + modalStoreFilter = ModalStore.store.filter; } - if (boardsStore) { - boardsStore[$dropdown.data('fieldName')] = selected.name; + if (modalStoreFilter) { + modalStoreFilter[$dropdown.data('fieldName')] = selected.name; e.preventDefault(); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { return Issuable.filterResults($dropdown.closest('form')); diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js index 0d8f31d6bfc..196b84621b6 100644 --- a/app/assets/javascripts/mirrors/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -30,6 +30,7 @@ export default class MirrorRepos { this.$password.on('input.updateUrl', () => this.debouncedUpdateUrl()); this.initMirrorSSH(); + this.updateProtectedBranches(); } initMirrorSSH() { diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index 815063237fc..64a1df80a8e 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -105,6 +105,9 @@ export default { deploymentFlagData() { return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag); }, + shouldRenderData() { + return this.graphData.queries.filter(s => s.result.length > 0).length > 0; + }, }, watch: { hoverData() { @@ -120,17 +123,17 @@ export default { }, draw() { const breakpointSize = bp.getBreakpointSize(); - const query = this.graphData.queries[0]; const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width; + this.margin = measurements.large.margin; + if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') { this.graphHeight = 300; this.margin = measurements.small.margin; this.measurements = measurements.small; } - this.unitOfDisplay = query.unit || ''; + this.yAxisLabel = this.graphData.y_label || 'Values'; - this.legendTitle = query.label || 'Average'; this.graphWidth = svgWidth - this.margin.left - this.margin.right; this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; this.baseGraphHeight = this.graphHeight - 50; @@ -139,8 +142,15 @@ export default { // pixel offsets inside the svg and outside are not 1:1 this.realPixelRatio = svgWidth / this.baseGraphWidth; - this.renderAxesPaths(); - this.formatDeployments(); + // set the legends on the axes + const [query] = this.graphData.queries; + this.legendTitle = query ? query.label : 'Average'; + this.unitOfDisplay = query ? query.unit : ''; + + if (this.shouldRenderData) { + this.renderAxesPaths(); + this.formatDeployments(); + } }, handleMouseOverGraph(e) { let point = this.$refs.graphData.createSVGPoint(); @@ -266,7 +276,7 @@ export default { :y-axis-label="yAxisLabel" :unit-of-display="unitOfDisplay" /> - <svg ref="graphData" :viewBox="innerViewBox" class="graph-data"> + <svg v-if="shouldRenderData" ref="graphData" :viewBox="innerViewBox" class="graph-data"> <slot name="additionalSvgContent" :graphDrawData="graphDrawData" /> <graph-path v-for="(path, index) in timeSeries" @@ -293,8 +303,14 @@ export default { @mousemove="handleMouseOverGraph($event);" /> </svg> + <svg v-else :viewBox="innerViewBox" class="js-no-data-to-display"> + <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle"> + {{ s__('Metrics|No data to display') }} + </text> + </svg> </svg> <graph-flag + v-if="shouldRenderData" :real-pixel-ratio="realPixelRatio" :current-x-coordinate="currentXCoordinate" :current-data="currentData" diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 176f7d9eef2..8692c873a41 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -7,10 +7,29 @@ function sortMetrics(metrics) { .value(); } +function checkQueryEmptyData(query) { + return { + ...query, + result: query.result.filter(timeSeries => { + const newTimeSeries = timeSeries; + const hasValue = series => + !Number.isNaN(series.value) && (series.value !== null || series.value !== undefined); + const hasNonNullValue = timeSeries.values.find(hasValue); + + newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : []; + + return newTimeSeries.values.length > 0; + }), + }; +} + +function removeTimeSeriesNoData(queries) { + return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []); +} + function normalizeMetrics(metrics) { - return metrics.map(metric => ({ - ...metric, - queries: metric.queries.map(query => ({ + return metrics.map(metric => { + const queries = metric.queries.map(query => ({ ...query, result: query.result.map(result => ({ ...result, @@ -19,8 +38,13 @@ function normalizeMetrics(metrics) { value: Number(value), })), })), - })), - })); + })); + + return { + ...metric, + queries: removeTimeSeriesNoData(queries), + }; + }); } export default class MonitoringStore { diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 884ccca7bde..ce56beb1e6b 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -4,6 +4,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import _ from 'underscore'; import Autosize from 'autosize'; import { __, sprintf } from '~/locale'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import Autosave from '../../autosave'; import { @@ -30,6 +31,7 @@ export default { markdownField, userAvatarLink, loadingButton, + TimelineEntryItem, }, mixins: [issuableStateMixin], props: { @@ -245,15 +247,19 @@ Please check your network connection and try again.`; } else { this.reopenIssue() .then(() => this.enableButton()) - .catch(() => { + .catch(({ data }) => { this.enableButton(); this.toggleStateButtonLoading(false); - Flash( - sprintf( - __('Something went wrong while reopening the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, - ), + let errorMessage = sprintf( + __('Something went wrong while reopening the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, ); + + if (data) { + errorMessage = Object.values(data).join('\n'); + } + + Flash(errorMessage); }); } }, @@ -309,137 +315,135 @@ Please check your network connection and try again.`; <div> <note-signed-out-widget v-if="!isLoggedIn" /> <discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="issuableTypeTitle" /> - <div v-else-if="canCreateNote" class="notes notes-form timeline"> - <div class="timeline-entry note-form"> - <div class="timeline-entry-inner"> - <div class="flash-container error-alert timeline-content"></div> - <div class="timeline-icon d-none d-sm-none d-md-block"> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - /> - </div> - <div class="timeline-content timeline-content-form"> - <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> - <div class="error-alert"></div> + <ul v-else-if="canCreateNote" class="notes notes-form timeline"> + <timeline-entry-item class="note-form"> + <div class="flash-container error-alert timeline-content"></div> + <div class="timeline-icon d-none d-sm-none d-md-block"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content timeline-content-form"> + <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> + <div class="error-alert"></div> - <issue-warning - v-if="hasWarning(getNoteableData)" - :is-locked="isLocked(getNoteableData)" - :is-confidential="isConfidential(getNoteableData)" - /> + <issue-warning + v-if="hasWarning(getNoteableData)" + :is-locked="isLocked(getNoteableData)" + :is-confidential="isConfidential(getNoteableData)" + /> - <markdown-field - ref="markdownField" - :markdown-preview-path="markdownPreviewPath" - :markdown-docs-path="markdownDocsPath" - :quick-actions-docs-path="quickActionsDocsPath" - :markdown-version="markdownVersion" - :add-spacing-classes="false" - > - <textarea - id="note-body" - ref="textarea" - slot="textarea" - v-model="note" - :disabled="isSubmitting" - name="note[note]" - class="note-textarea js-vue-comment-form js-note-text + <markdown-field + ref="markdownField" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :markdown-version="markdownVersion" + :add-spacing-classes="false" + > + <textarea + id="note-body" + ref="textarea" + slot="textarea" + v-model="note" + :disabled="isSubmitting" + name="note[note]" + class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" - data-supports-quick-actions="true" - aria-label="Description" - placeholder="Write a comment or drag your files here…" - @keydown.up="editCurrentUserLastNote();" - @keydown.meta.enter="handleSave();" - @keydown.ctrl.enter="handleSave();" - > - </textarea> - </markdown-field> - <div class="note-form-actions"> - <div - class="float-left btn-group + data-supports-quick-actions="true" + aria-label="Description" + placeholder="Write a comment or drag your files here…" + @keydown.up="editCurrentUserLastNote();" + @keydown.meta.enter="handleSave();" + @keydown.ctrl.enter="handleSave();" + > + </textarea> + </markdown-field> + <div class="note-form-actions"> + <div + class="float-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" - > - <button - :disabled="isSubmitButtonDisabled" - class="btn btn-create comment-btn js-comment-button js-comment-submit-button + > + <button + :disabled="isSubmitButtonDisabled" + class="btn btn-create comment-btn js-comment-button js-comment-submit-button qa-comment-button" - type="submit" - @click.prevent="handleSave();" - > - {{ __(commentButtonTitle) }} - </button> - <button - :disabled="isSubmitButtonDisabled" - name="button" - type="button" - class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" - data-display="static" - data-toggle="dropdown" - aria-label="Open comment type dropdown" - > - <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i> - </button> - - <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> - <li :class="{ 'droplab-item-selected': noteType === 'comment' }"> - <button - type="button" - class="btn btn-transparent" - @click.prevent="setNoteType('comment');" - > - <i aria-hidden="true" class="fa fa-check icon"> </i> - <div class="description"> - <strong>Comment</strong> - <p>Add a general comment to this {{ noteableDisplayName }}.</p> - </div> - </button> - </li> - <li class="divider droplab-item-ignore"></li> - <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> - <button - type="button" - class="btn btn-transparent qa-discussion-option" - @click.prevent="setNoteType('discussion');" - > - <i aria-hidden="true" class="fa fa-check icon"> </i> - <div class="description"> - <strong>Start discussion</strong> - <p>{{ startDiscussionDescription }}</p> - </div> - </button> - </li> - </ul> - </div> - - <loading-button - v-if="canUpdateIssue" - :loading="isToggleStateButtonLoading" - :container-class="[ - actionButtonClassNames, - 'btn btn-comment btn-comment-and-close js-action-button', - ]" - :disabled="isToggleStateButtonLoading || isSubmitting" - :label="issueActionButtonTitle" - @click="handleSave(true);" - /> - + type="submit" + @click.prevent="handleSave();" + > + {{ __(commentButtonTitle) }} + </button> <button - v-if="note.length" + :disabled="isSubmitButtonDisabled" + name="button" type="button" - class="btn btn-cancel js-note-discard" - @click="discard" + class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" + data-display="static" + data-toggle="dropdown" + aria-label="Open comment type dropdown" > - Discard draft + <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i> </button> + + <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> + <li :class="{ 'droplab-item-selected': noteType === 'comment' }"> + <button + type="button" + class="btn btn-transparent" + @click.prevent="setNoteType('comment');" + > + <i aria-hidden="true" class="fa fa-check icon"> </i> + <div class="description"> + <strong>Comment</strong> + <p>Add a general comment to this {{ noteableDisplayName }}.</p> + </div> + </button> + </li> + <li class="divider droplab-item-ignore"></li> + <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> + <button + type="button" + class="btn btn-transparent qa-discussion-option" + @click.prevent="setNoteType('discussion');" + > + <i aria-hidden="true" class="fa fa-check icon"> </i> + <div class="description"> + <strong>Start discussion</strong> + <p>{{ startDiscussionDescription }}</p> + </div> + </button> + </li> + </ul> </div> - </form> - </div> + + <loading-button + v-if="canUpdateIssue" + :loading="isToggleStateButtonLoading" + :container-class="[ + actionButtonClassNames, + 'btn btn-comment btn-comment-and-close js-action-button', + ]" + :disabled="isToggleStateButtonLoading || isSubmitting" + :label="issueActionButtonTitle" + @click="handleSave(true);" + /> + + <button + v-if="note.length" + type="button" + class="btn btn-cancel js-note-discard" + @click="discard" + > + Discard draft + </button> + </div> + </form> </div> - </div> - </div> + </timeline-entry-item> + </ul> </div> </template> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 8e8bd150647..af821df0fd2 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -4,7 +4,9 @@ import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { GlSkeletonLoading } from '@gitlab/ui'; -import { trimFirstCharOfLineContent, getDiffMode } from '~/diffs/store/utils'; +import { getDiffMode } from '~/diffs/store/utils'; + +const FIRST_CHAR_REGEX = /^(\+|-| )/; export default { components: { @@ -26,46 +28,16 @@ export default { }, computed: { ...mapState({ - noteableData: state => state.notes.noteableData, projectPath: state => state.diffs.projectPath, }), diffMode() { - return getDiffMode(this.diffFile); + return getDiffMode(this.discussion.diff_file); }, hasTruncatedDiffLines() { return ( this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0 ); }, - isDiscussionsExpanded() { - return true; // TODO: @fatihacet - Fix this. - }, - isCollapsed() { - return this.diffFile.collapsed || false; - }, - isImageDiff() { - return !this.diffFile.text; - }, - diffFileClass() { - const { text } = this.diffFile; - return text ? 'text-file' : 'js-image-file'; - }, - diffFile() { - return this.discussion.diff_file; - }, - imageDiffHtml() { - return this.discussion.image_diff_html; - }, - userColorScheme() { - return window.gon.user_color_scheme; - }, - normalizedDiffLines() { - if (this.discussion.truncated_diff_lines) { - return this.discussion.truncated_diff_lines.map(line => trimFirstCharOfLineContent(line)); - } - - return []; - }, }, mounted() { if (!this.hasTruncatedDiffLines) { @@ -74,9 +46,6 @@ export default { }, methods: { ...mapActions(['fetchDiscussionDiffLines']), - rowTag(html) { - return html.outerHTML ? 'tr' : 'template'; - }, fetchDiff() { this.error = false; this.fetchDiscussionDiffLines(this.discussion) @@ -85,31 +54,45 @@ export default { this.error = true; }); }, + trimChar(line) { + return line.replace(FIRST_CHAR_REGEX, ''); + }, }, + userColorSchemeClass: window.gon.user_color_scheme, }; </script> <template> - <div ref="fileHolder" :class="diffFileClass" class="diff-file file-holder"> + <div :class="{ 'text-file': discussion.diff_file.text }" class="diff-file file-holder"> <diff-file-header :discussion-path="discussion.discussion_path" - :diff-file="diffFile" + :diff-file="discussion.diff_file" :can-current-user-fork="false" - :discussions-expanded="isDiscussionsExpanded" - :expanded="!isCollapsed" + :expanded="!discussion.diff_file.collapsed" /> - <div v-if="diffFile.text" :class="userColorScheme" class="diff-content code"> + <div + v-if="discussion.diff_file.text" + :class="$options.userColorSchemeClass" + class="diff-content code" + > <table> - <tr v-for="line in normalizedDiffLines" :key="line.line_code" class="line_holder"> - <td class="diff-line-num old_line">{{ line.old_line }}</td> - <td class="diff-line-num new_line">{{ line.new_line }}</td> - <td :class="line.type" class="line_content" v-html="line.rich_text"></td> - </tr> + <template v-if="hasTruncatedDiffLines"> + <tr + v-for="line in discussion.truncated_diff_lines" + v-once + :key="line.line_code" + class="line_holder" + > + <td class="diff-line-num old_line">{{ line.old_line }}</td> + <td class="diff-line-num new_line">{{ line.new_line }}</td> + <td :class="line.type" class="line_content" v-html="trimChar(line.rich_text)"></td> + </tr> + </template> <tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder"> <td class="old_line diff-line-num"></td> <td class="new_line diff-line-num"></td> <td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block"> - Unable to load the diff + {{ error }} Unable to load the diff <button class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button" @click="fetchDiff" @@ -131,17 +114,17 @@ export default { <div v-else> <diff-viewer :diff-mode="diffMode" - :new-path="diffFile.new_path" - :new-sha="diffFile.diff_refs.head_sha" - :old-path="diffFile.old_path" - :old-sha="diffFile.diff_refs.base_sha" - :file-hash="diffFile.file_hash" + :new-path="discussion.diff_file.new_path" + :new-sha="discussion.diff_file.diff_refs.head_sha" + :old-path="discussion.diff_file.old_path" + :old-sha="discussion.diff_file.diff_refs.base_sha" + :file-hash="discussion.diff_file.file_hash" :project-path="projectPath" > <image-diff-overlay slot="image-overlay" :discussions="discussion" - :file-hash="diffFile.file_hash" + :file-hash="discussion.diff_file.file_hash" :show-comment-icon="true" :should-toggle-discussion="false" badge-class="image-comment-badge" diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index ee79ecbf9b3..c7cfc0f0f3b 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,13 +1,12 @@ <script> import { mapActions, mapGetters } from 'vuex'; +import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; -import { pluralize } from '../../lib/utils/text_utility'; import discussionNavigation from '../mixins/discussion_navigation'; -import tooltip from '../../vue_shared/directives/tooltip'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { Icon, @@ -17,9 +16,9 @@ export default { ...mapGetters([ 'getUserData', 'getNoteableData', - 'discussionCount', + 'resolvableDiscussionsCount', 'firstUnresolvedDiscussionId', - 'resolvedDiscussionCount', + 'unresolvedDiscussionsCount', ]), isLoggedIn() { return this.getUserData.id; @@ -27,15 +26,15 @@ export default { hasNextButton() { return this.isLoggedIn && !this.allResolved; }, - countText() { - return pluralize('discussion', this.discussionCount); - }, allResolved() { - return this.resolvedDiscussionCount === this.discussionCount; + return this.unresolvedDiscussionsCount === 0; }, resolveAllDiscussionsIssuePath() { return this.getNoteableData.create_issue_to_resolve_discussions_path; }, + resolvedDiscussionsCount() { + return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount; + }, }, methods: { ...mapActions(['expandDiscussion']), @@ -50,7 +49,7 @@ export default { </script> <template> - <div v-if="discussionCount > 0" class="line-resolve-all-container prepend-top-8"> + <div v-if="resolvableDiscussionsCount > 0" class="line-resolve-all-container prepend-top-8"> <div> <div :class="{ 'has-next-btn': hasNextButton }" class="line-resolve-all"> <span @@ -61,15 +60,15 @@ export default { <icon name="check-circle" /> </span> <span class="line-resolve-text"> - {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved + {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }} + {{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }} </span> </div> <div v-if="resolveAllDiscussionsIssuePath && !allResolved" class="btn-group" role="group"> <a - v-tooltip + v-gl-tooltip :href="resolveAllDiscussionsIssuePath" :title="s__('Resolve all discussions in new issue')" - data-container="body" class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" > <icon name="issue-new" /> @@ -77,9 +76,8 @@ export default { </div> <div v-if="isLoggedIn && !allResolved" class="btn-group" role="group"> <button - v-tooltip + v-gl-tooltip title="Jump to first unresolved discussion" - data-container="body" class="btn btn-default discussion-next-btn" @click="jumpToFirstUnresolvedDiscussion" > diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 9a5817890c9..d99694b06e9 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,8 +1,7 @@ <script> import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; export default { name: 'NoteActions', @@ -11,7 +10,7 @@ export default { GlLoadingIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { authorId: { @@ -119,10 +118,10 @@ export default { <template> <div class="note-actions"> - <span v-if="accessLevel" class="note-role user-access-role"> {{ accessLevel }} </span> + <span v-if="accessLevel" class="note-role user-access-role">{{ accessLevel }}</span> <div v-if="canResolve" class="note-actions-item"> <button - v-tooltip + v-gl-tooltip :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" :title="resolveButtonTitle" :aria-label="resolveButtonTitle" @@ -138,12 +137,10 @@ export default { </div> <div v-if="canAwardEmoji" class="note-actions-item"> <a - v-tooltip + v-gl-tooltip.bottom :class="{ 'js-user-authored': isAuthoredByCurrentUser }" class="note-action-button note-emoji-button js-add-award js-note-emoji" data-position="right" - data-placement="bottom" - data-container="body" href="#" title="Add reaction" > @@ -158,12 +155,10 @@ export default { </div> <div v-if="canEdit" class="note-actions-item"> <button - v-tooltip + v-gl-tooltip.bottom type="button" title="Edit comment" class="note-action-button js-note-edit btn btn-transparent" - data-container="body" - data-placement="bottom" @click="onEdit" > <icon name="pencil" css-classes="link-highlight" /> @@ -171,12 +166,10 @@ export default { </div> <div v-if="showDeleteAction" class="note-actions-item"> <button - v-tooltip + v-gl-tooltip.bottom type="button" title="Delete comment" class="note-action-button js-note-delete btn btn-transparent" - data-container="body" - data-placement="bottom" @click="onDelete" > <icon name="remove" class="link-highlight" /> @@ -184,19 +177,17 @@ export default { </div> <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item"> <button - v-tooltip + v-gl-tooltip.bottom type="button" title="More actions" class="note-action-button more-actions-toggle btn btn-transparent" data-toggle="dropdown" - data-container="body" - data-placement="bottom" > <icon css-classes="icon" name="ellipsis_v" /> </button> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <li v-if="canReportAsAbuse"> - <a :href="reportAbusePath"> {{ __('Report abuse to GitLab') }} </a> + <a :href="reportAbusePath">{{ __('Report abuse to GitLab') }}</a> </li> <li v-if="noteUrl"> <button @@ -213,7 +204,7 @@ export default { type="button" @click.prevent="onDelete" > - <span class="text-danger"> {{ __('Delete comment') }} </span> + <span class="text-danger">{{ __('Delete comment') }}</span> </button> </li> </ul> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 4aba2e65edb..3d60eb02db8 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,16 +1,16 @@ <script> import { mapActions, mapGetters } from 'vuex'; +import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import { glEmojiTag } from '../../emoji'; -import tooltip from '../../vue_shared/directives/tooltip'; export default { components: { Icon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { awards: { @@ -167,21 +167,19 @@ export default { <button v-for="(awardList, awardName, index) in groupedAwards" :key="index" - v-tooltip + v-gl-tooltip.bottom="{ boundary: 'viewport' }" :class="getAwardClassBindings(awardList)" :title="awardTitle(awardList)" class="btn award-control" - data-boundary="viewport" - data-placement="bottom" type="button" @click="handleAward(awardName);" > <span v-html="getAwardHTML(awardName)"></span> - <span class="award-control-text js-counter"> {{ awardList.length }} </span> + <span class="award-control-text js-counter">{{ awardList.length }}</span> </button> <div v-if="canAwardEmoji" class="award-menu-holder"> <button - v-tooltip + v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByMe }" class="award-control btn js-add-award" title="Add reaction" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index ad58267b533..95164183ccb 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -48,13 +48,19 @@ export default { required: false, default: '', }, + resolveDiscussion: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { updatedNoteBody: this.noteBody, conflictWhileEditing: false, isSubmitting: false, - isResolving: false, + isResolving: this.resolveDiscussion, + isUnresolving: !this.resolveDiscussion, resolveAsThread: true, }; }, @@ -149,7 +155,7 @@ export default { <div ref="editNoteForm" class="note-edit-form current-note-edit-form js-discussion-note-form"> <div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger"> This comment has changed since you started editing, please review the - <a :href="noteHash" target="_blank" rel="noopener noreferrer"> updated comment </a> to ensure + <a :href="noteHash" target="_blank" rel="noopener noreferrer">updated comment</a> to ensure information is not lost. </div> <div class="flash-container timeline-content"></div> @@ -174,22 +180,20 @@ export default { v-model="updatedNoteBody" :data-supports-quick-actions="!isEditing" name="note[note]" - class="note-textarea js-gfm-input js-note-text -js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" + class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" aria-label="Description" placeholder="Write a comment or drag your files here…" @keydown.meta.enter="handleUpdate();" @keydown.ctrl.enter="handleUpdate();" @keydown.up="editMyLastNote();" @keydown.esc="cancelHandler(true);" - > - </textarea> + ></textarea> </markdown-field> <div class="note-form-actions clearfix"> <button :disabled="isDisabled" type="button" - class="js-vue-issue-save btn btn-success js-comment-button " + class="js-vue-issue-save btn btn-success js-comment-button" @click="handleUpdate();" > {{ saveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 8b7450783c9..e1a58e7cb26 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -73,7 +73,7 @@ export default { {{ __('Toggle discussion') }} </button> </div> - <a v-if="hasAuthor" :href="author.path"> + <a v-if="hasAuthor" v-once :href="author.path"> <span class="note-header-author-name">{{ author.name }}</span> <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> <span class="note-headline-light"> @{{ author.username }} </span> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 29740ddf6ae..f4991a41325 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,9 +1,12 @@ <script> +import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; +import { GlTooltipDirective } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; -import { s__ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import systemNote from '~/vue_shared/components/notes/system_note.vue'; import icon from '~/vue_shared/components/icon.vue'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -20,14 +23,12 @@ import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; -import tooltip from '../../vue_shared/directives/tooltip'; export default { name: 'NoteableDiscussion', components: { icon, noteableNote, - diffWithNote, userAvatarLink, noteHeader, noteSignedOutWidget, @@ -37,9 +38,10 @@ export default { placeholderNote, placeholderSystemNote, systemNote, + TimelineEntryItem, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, mixins: [autosave, noteable, resolvable, discussionNavigation], props: { @@ -64,43 +66,24 @@ export default { }, }, data() { + const { diff_discussion: isDiffDiscussion, resolved } = this.discussion; + return { isReplying: false, isResolving: false, resolveAsThread: true, - isRepliesToggledByUser: false, + isRepliesCollapsed: Boolean(!isDiffDiscussion && resolved), }; }, computed: { ...mapGetters([ 'getNoteableData', - 'discussionCount', - 'resolvedDiscussionCount', - 'allDiscussions', - 'unresolvedDiscussionsIdsByDiff', - 'unresolvedDiscussionsIdsByDate', - 'unresolvedDiscussions', - 'unresolvedDiscussionsIdsOrdered', 'nextUnresolvedDiscussionId', - 'isLastUnresolvedDiscussion', + 'unresolvedDiscussionsCount', + 'hasUnresolvedDiscussions', ]), - transformedDiscussion() { - return { - ...this.discussion.notes[0], - truncated_diff_lines: this.discussion.truncated_diff_lines || [], - truncated_diff_lines_path: this.discussion.truncated_diff_lines_path, - diff_file: this.discussion.diff_file, - diff_discussion: this.discussion.diff_discussion, - active: this.discussion.active, - discussion_path: this.discussion.discussion_path, - resolved: this.discussion.resolved, - resolved_by: this.discussion.resolved_by, - resolved_by_push: this.discussion.resolved_by_push, - resolved_at: this.discussion.resolved_at, - }; - }, author() { - return this.transformedDiscussion.author; + return this.initialDiscussion.author; }, canReply() { return this.getNoteableData.current_user.can_create_note; @@ -136,29 +119,13 @@ export default { return null; }, resolvedText() { - return this.transformedDiscussion.resolved_by_push ? 'Automatically resolved' : 'Resolved'; - }, - hasMultipleUnresolvedDiscussions() { - return this.unresolvedDiscussions.length > 1; - }, - showJumpToNextDiscussion() { - return ( - this.hasMultipleUnresolvedDiscussions && - !this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder) - ); + return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); }, shouldRenderDiffs() { - return ( - this.transformedDiscussion.diff_discussion && - this.transformedDiscussion.diff_file && - this.renderDiffFile - ); + return this.discussion.diff_discussion && this.renderDiffFile; }, shouldGroupReplies() { - return !this.shouldRenderDiffs && !this.transformedDiscussion.diff_discussion; - }, - shouldRenderHeader() { - return this.shouldRenderDiffs; + return !this.shouldRenderDiffs && !this.discussion.diff_discussion; }, wrapperComponent() { return this.shouldRenderDiffs ? diffWithNote : 'div'; @@ -170,9 +137,6 @@ export default { return {}; }, - wrapperClass() { - return this.isDiffDiscussion ? '' : 'card discussion-wrapper'; - }, componentClassName() { if (this.shouldRenderDiffs) { if (!this.lastUpdatedAt && !this.discussion.resolved) { @@ -183,19 +147,40 @@ export default { return ''; }, shouldShowDiscussions() { - const isExpanded = this.discussion.expanded; - const { resolved } = this.transformedDiscussion; - const isResolvedNonDiffDiscussion = !this.transformedDiscussion.diff_discussion && resolved; + const { expanded, resolved } = this.discussion; + const isResolvedNonDiffDiscussion = !this.discussion.diff_discussion && resolved; - return isExpanded || this.alwaysExpanded || isResolvedNonDiffDiscussion; + return expanded || this.alwaysExpanded || isResolvedNonDiffDiscussion; }, - isRepliesCollapsed() { - const { discussion, isRepliesToggledByUser } = this; - const { resolved, notes } = discussion; - const hasReplies = notes.length > 1; + actionText() { + const commitId = this.discussion.commit_id ? truncateSha(this.discussion.commit_id) : ''; + const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; + const linkEnd = '</a>'; + + let text = s__('MergeRequests|started a discussion'); - return ( - (!discussion.diff_discussion && resolved && hasReplies && !isRepliesToggledByUser) || false + if (this.discussion.for_commit) { + text = s__( + 'MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}', + ); + } else if (this.discussion.diff_discussion) { + if (this.discussion.active) { + text = s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}'); + } else { + text = s__( + 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}', + ); + } + } + + return sprintf( + text, + { + commitId, + linkStart, + linkEnd, + }, + false, ); }, }, @@ -204,7 +189,7 @@ export default { if (this.isReplying) { this.$nextTick(() => { // Pass an extra key to separate reply and note edit forms - this.initAutoSave(this.transformedDiscussion, ['Reply']); + this.initAutoSave({ ...this.initialDiscussion, ...this.discussion }, ['Reply']); }); } else { this.disposeAutoSave(); @@ -242,7 +227,7 @@ export default { this.toggleDiscussion({ discussionId: this.discussion.id }); }, toggleReplies() { - this.isRepliesToggledByUser = !this.isRepliesToggledByUser; + this.isRepliesCollapsed = !this.isRepliesCollapsed; }, showReplyForm() { this.isReplying = true; @@ -311,181 +296,156 @@ Please check your network connection and try again.`; </script> <template> - <li class="note note-discussion timeline-entry" :class="componentClassName"> - <div class="timeline-entry-inner"> - <div class="timeline-content"> - <div - :data-discussion-id="transformedDiscussion.discussion_id" - class="discussion js-discussion-container" - > - <div v-if="shouldRenderHeader" class="discussion-header note-wrapper"> - <div class="timeline-icon"> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - /> - </div> - <note-header - :author="author" - :created-at="transformedDiscussion.created_at" - :note-id="transformedDiscussion.id" - :include-toggle="true" - :expanded="discussion.expanded" - @toggleHandler="toggleDiscussionHandler" - > - <template v-if="transformedDiscussion.diff_discussion"> - started a discussion on - <a :href="transformedDiscussion.discussion_path"> - <template v-if="transformedDiscussion.active"> - the diff - </template> - <template v-else> - an old version of the diff - </template> - </a> - </template> - <template v-else-if="discussion.for_commit"> - started a discussion on commit - <a :href="discussion.discussion_path"> {{ truncateSha(discussion.commit_id) }} </a> - </template> - <template v-else> - started a discussion - </template> - </note-header> - <note-edited-text - v-if="transformedDiscussion.resolved" - :edited-at="transformedDiscussion.resolved_at" - :edited-by="transformedDiscussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline" - /> - <note-edited-text - v-else-if="lastUpdatedAt" - :edited-at="lastUpdatedAt" - :edited-by="lastUpdatedBy" - action-text="Last updated" - class-name="discussion-headline-light js-discussion-headline" + <timeline-entry-item class="note note-discussion" :class="componentClassName"> + <div class="timeline-content"> + <div :data-discussion-id="discussion.id" class="discussion js-discussion-container"> + <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper"> + <div v-once class="timeline-icon"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" /> </div> - <div v-if="shouldShowDiscussions" class="discussion-body"> - <component :is="wrapperComponent" v-bind="wrapperComponentProps" :class="wrapperClass"> - <div class="discussion-notes"> - <ul class="notes"> - <template v-if="shouldGroupReplies"> - <component - :is="componentName(initialDiscussion)" - :note="componentData(initialDiscussion)" - @handleDeleteNote="deleteNoteHandler" - > - <slot slot="avatar-badge" name="avatar-badge"> </slot> - </component> - <toggle-replies-widget - v-if="hasReplies" - :collapsed="isRepliesCollapsed" - :replies="replies" - @toggle="toggleReplies" - /> - <template v-if="!isRepliesCollapsed"> - <component - :is="componentName(note)" - v-for="note in replies" - :key="note.id" - :note="componentData(note)" - @handleDeleteNote="deleteNoteHandler" - /> - </template> - </template> - <template v-else> + <note-header + :author="author" + :created-at="initialDiscussion.created_at" + :note-id="initialDiscussion.id" + :include-toggle="true" + :expanded="discussion.expanded" + @toggleHandler="toggleDiscussionHandler" + > + <span v-html="actionText"></span> + </note-header> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline" + /> + <note-edited-text + v-else-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + action-text="Last updated" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> + <div v-if="shouldShowDiscussions" class="discussion-body"> + <component + :is="wrapperComponent" + v-bind="wrapperComponentProps" + class="card discussion-wrapper" + > + <div class="discussion-notes"> + <ul class="notes"> + <template v-if="shouldGroupReplies"> + <component + :is="componentName(initialDiscussion)" + :note="componentData(initialDiscussion)" + @handleDeleteNote="deleteNoteHandler" + > + <slot slot="avatar-badge" name="avatar-badge"></slot> + </component> + <toggle-replies-widget + v-if="hasReplies" + :collapsed="isRepliesCollapsed" + :replies="replies" + @toggle="toggleReplies" + /> + <template v-if="!isRepliesCollapsed"> <component :is="componentName(note)" - v-for="(note, index) in discussion.notes" + v-for="note in replies" :key="note.id" :note="componentData(note)" @handleDeleteNote="deleteNoteHandler" - > - <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"> </slot> - </component> + /> </template> - </ul> - <div - v-if="!isRepliesCollapsed" - :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder" - > - <template v-if="!isReplying && canReply"> - <div class="discussion-with-resolve-btn"> + </template> + <template v-else> + <component + :is="componentName(note)" + v-for="(note, index) in discussion.notes" + :key="note.id" + :note="componentData(note)" + @handleDeleteNote="deleteNoteHandler" + > + <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> + </component> + </template> + </ul> + <div + v-if="!isRepliesCollapsed" + :class="{ 'is-replying': isReplying }" + class="discussion-reply-holder" + > + <template v-if="!isReplying && canReply"> + <div class="discussion-with-resolve-btn"> + <button + type="button" + class="js-vue-discussion-reply btn btn-text-field mr-sm-2 qa-discussion-reply" + title="Add a reply" + @click="showReplyForm" + > + Reply... + </button> + <div v-if="discussion.resolvable"> <button type="button" - class="js-vue-discussion-reply btn btn-text-field mr-sm-2 qa-discussion-reply" - title="Add a reply" - @click="showReplyForm" + class="btn btn-default mr-sm-2" + @click="resolveHandler();" > - Reply... + <i v-if="isResolving" aria-hidden="true" class="fa fa-spinner fa-spin"></i> + {{ resolveButtonTitle }} </button> - <div v-if="discussion.resolvable"> + </div> + <div + v-if="discussion.resolvable" + class="btn-group discussion-actions ml-sm-2" + role="group" + > + <div v-if="!discussionResolved" class="btn-group" role="group"> + <a + v-gl-tooltip + :href="discussion.resolve_with_issue_path" + :title="s__('MergeRequests|Resolve this discussion in a new issue')" + class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" + > + <icon name="issue-new" /> + </a> + </div> + <div v-if="hasUnresolvedDiscussions" class="btn-group" role="group"> <button - type="button" - class="btn btn-default mr-sm-2" - @click="resolveHandler();" + v-gl-tooltip + class="btn btn-default discussion-next-btn" + title="Jump to next unresolved discussion" + @click="jumpToNextDiscussion" > - <i - v-if="isResolving" - aria-hidden="true" - class="fa fa-spinner fa-spin" - ></i> - {{ resolveButtonTitle }} + <icon name="comment-next" /> </button> </div> - <div - v-if="discussion.resolvable" - class="btn-group discussion-actions ml-sm-2" - role="group" - > - <div v-if="!discussionResolved" class="btn-group" role="group"> - <a - v-tooltip - :href="discussion.resolve_with_issue_path" - :title="s__('MergeRequests|Resolve this discussion in a new issue')" - class="new-issue-for-discussion btn - btn-default discussion-create-issue-btn" - data-container="body" - > - <icon name="issue-new" /> - </a> - </div> - <div v-if="showJumpToNextDiscussion" class="btn-group" role="group"> - <button - v-tooltip - class="btn btn-default discussion-next-btn" - title="Jump to next unresolved discussion" - data-container="body" - @click="jumpToNextDiscussion" - > - <icon name="comment-next" /> - </button> - </div> - </div> </div> - </template> - <note-form - v-if="isReplying" - ref="noteForm" - :discussion="discussion" - :is-editing="false" - save-button-title="Comment" - @handleFormUpdate="saveReply" - @cancelForm="cancelReplyForm" - /> - <note-signed-out-widget v-if="!canReply" /> - </div> + </div> + </template> + <note-form + v-if="isReplying" + ref="noteForm" + :discussion="discussion" + :is-editing="false" + save-button-title="Comment" + @handleFormUpdate="saveReply" + @cancelForm="cancelReplyForm" + /> + <note-signed-out-widget v-if="!canReply" /> </div> - </component> - </div> + </div> + </component> </div> </div> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index c2e49f8b23f..a17be51353e 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -2,6 +2,7 @@ import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import { escape } from 'underscore'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteHeader from './note_header.vue'; @@ -18,6 +19,7 @@ export default { noteHeader, noteActions, noteBody, + TimelineEntryItem, }, mixins: [noteable, resolvable], props: { @@ -169,61 +171,60 @@ export default { </script> <template> - <li + <timeline-entry-item :id="noteAnchorId" :class="classNameBindings" :data-award-url="note.toggle_award_path" :data-note-id="note.id" - class="note timeline-entry note-wrapper" + class="note note-wrapper" > - <div class="timeline-entry-inner"> - <div class="timeline-icon"> - <user-avatar-link - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - > - <slot slot="avatar-badge" name="avatar-badge"> </slot> - </user-avatar-link> - </div> - <div class="timeline-content"> - <div class="note-header"> - <note-header - :author="author" - :created-at="note.created_at" - :note-id="note.id" - action-text="commented" - /> - <note-actions - :author-id="author.id" - :note-id="note.id" - :note-url="note.noteable_note_url" - :access-level="note.human_access" - :can-edit="note.current_user.can_edit" - :can-award-emoji="note.current_user.can_award_emoji" - :can-delete="note.current_user.can_edit" - :can-report-as-abuse="canReportAsAbuse" - :can-resolve="note.current_user.can_resolve" - :report-abuse-path="note.report_abuse_path" - :resolvable="note.resolvable" - :is-resolved="note.resolved" - :is-resolving="isResolving" - :resolved-by="note.resolved_by" - @handleEdit="editHandler" - @handleDelete="deleteHandler" - @handleResolve="resolveHandler" - /> - </div> - <note-body - ref="noteBody" - :note="note" + <div v-once class="timeline-icon"> + <user-avatar-link + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + > + <slot slot="avatar-badge" name="avatar-badge"> </slot> + </user-avatar-link> + </div> + <div class="timeline-content"> + <div class="note-header"> + <note-header + v-once + :author="author" + :created-at="note.created_at" + :note-id="note.id" + action-text="commented" + /> + <note-actions + :author-id="author.id" + :note-id="note.id" + :note-url="note.noteable_note_url" + :access-level="note.human_access" :can-edit="note.current_user.can_edit" - :is-editing="isEditing" - @handleFormUpdate="formUpdateHandler" - @cancelForm="formCancelHandler" + :can-award-emoji="note.current_user.can_award_emoji" + :can-delete="note.current_user.can_edit" + :can-report-as-abuse="canReportAsAbuse" + :can-resolve="note.current_user.can_resolve" + :report-abuse-path="note.report_abuse_path" + :resolvable="note.resolvable" + :is-resolved="note.resolved" + :is-resolving="isResolving" + :resolved-by="note.resolved_by" + @handleEdit="editHandler" + @handleDelete="deleteHandler" + @handleResolve="resolveHandler" /> </div> + <note-body + ref="noteBody" + :note="note" + :can-edit="note.current_user.can_edit" + :is-editing="isEditing" + @handleFormUpdate="formUpdateHandler" + @cancelForm="formCancelHandler" + /> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 79ece036e69..6e6efb04753 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -22,6 +22,7 @@ export default { commentForm, placeholderNote, placeholderSystemNote, + skeletonLoadingContainer, }, props: { noteableData: { @@ -59,7 +60,6 @@ export default { 'isNotesFetched', 'discussions', 'getNotesDataByProp', - 'discussionCount', 'isLoading', 'commentsDisabled', ]), @@ -109,39 +109,22 @@ export default { this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'))); }, methods: { - ...mapActions({ - setLoadingState: 'setLoadingState', - fetchDiscussions: 'fetchDiscussions', - poll: 'poll', - actionToggleAward: 'toggleAward', - scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', - setNotesData: 'setNotesData', - setNoteableData: 'setNoteableData', - setUserData: 'setUserData', - setLastFetchedAt: 'setLastFetchedAt', - setTargetNoteHash: 'setTargetNoteHash', - toggleDiscussion: 'toggleDiscussion', - setNotesFetchedState: 'setNotesFetchedState', - startTaskList: 'startTaskList', - }), - getComponentName(discussion) { - if (discussion.isSkeletonNote) { - return skeletonLoadingContainer; - } - if (discussion.isPlaceholderNote) { - if (discussion.placeholderType === constants.SYSTEM_NOTE) { - return placeholderSystemNote; - } - return placeholderNote; - } else if (discussion.individual_note) { - return discussion.notes[0].system ? systemNote : noteableNote; - } - - return noteableDiscussion; - }, - getComponentData(discussion) { - return discussion.individual_note ? { note: discussion.notes[0] } : { discussion }; - }, + ...mapActions([ + 'setLoadingState', + 'fetchDiscussions', + 'poll', + 'toggleAward', + 'scrollToNoteIfNeeded', + 'setNotesData', + 'setNoteableData', + 'setUserData', + 'setLastFetchedAt', + 'setTargetNoteHash', + 'toggleDiscussion', + 'setNotesFetchedState', + 'expandDiscussion', + 'startTaskList', + ]), fetchNotes() { if (this.isFetching) return null; @@ -181,31 +164,46 @@ export default { const noteId = hash && hash.replace(/^note_/, ''); if (noteId) { - this.discussions.forEach(discussion => { - if (discussion.notes) { - discussion.notes.forEach(note => { - if (`${note.id}` === `${noteId}`) { - // FIXME: this modifies the store state without using a mutation/action - Object.assign(discussion, { expanded: true }); - } - }); - } - }); + const discussion = this.discussions.find(d => d.notes.some(({ id }) => id === noteId)); + + if (discussion) { + this.expandDiscussion({ discussionId: discussion.id }); + } } }, }, + systemNote: constants.SYSTEM_NOTE, }; </script> <template> <div v-show="shouldShow" id="notes"> <ul id="notes-list" class="notes main-notes-list timeline"> - <component - :is="getComponentName(discussion)" - v-for="discussion in allDiscussions" - :key="discussion.id" - v-bind="getComponentData(discussion)" - /> + <template v-for="discussion in allDiscussions"> + <skeleton-loading-container v-if="discussion.isSkeletonNote" :key="discussion.id" /> + <template v-else-if="discussion.isPlaceholderNote"> + <placeholder-system-note + v-if="discussion.placeholderType === $options.systemNote" + :key="discussion.id" + :note="discussion.notes[0]" + /> + <placeholder-note v-else :key="discussion.id" :note="discussion.notes[0]" /> + </template> + <template v-else-if="discussion.individual_note"> + <system-note + v-if="discussion.notes[0].system" + :key="discussion.id" + :note="discussion.notes[0]" + /> + <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" /> + </template> + <noteable-discussion + v-else + :key="discussion.id" + :discussion="discussion" + :render-diff-file="true" + /> + </template> </ul> <comment-form diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index cd8394e0619..8edf3d088bb 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -36,7 +36,7 @@ export default { const discussion = this.resolveAsThread; const endpoint = discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`; - this.toggleResolveNote({ endpoint, isResolved, discussion }) + return this.toggleResolveNote({ endpoint, isResolved, discussion }) .then(() => { this.isResolving = false; }) diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 5b2f0540020..b4befdd6e4a 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -11,7 +11,7 @@ import * as constants from '../constants'; import service from '../services/notes_service'; import loadAwardsHandler from '../../awards_handler'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; -import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; +import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; import { __ } from '~/locale'; @@ -39,12 +39,13 @@ export const setNotesFetchedState = ({ commit }, state) => export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); -export const fetchDiscussions = ({ commit }, { path, filter }) => +export const fetchDiscussions = ({ commit, dispatch }, { path, filter }) => service .fetchDiscussions(path, filter) .then(res => res.json()) .then(discussions => { commit(types.SET_INITIAL_DISCUSSIONS, discussions); + dispatch('updateResolvableDiscussonsCounts'); }); export const updateDiscussion = ({ commit, state }, discussion) => { @@ -53,11 +54,18 @@ export const updateDiscussion = ({ commit, state }, discussion) => { return utils.findNoteObjectById(state.discussions, discussion.id); }; -export const deleteNote = ({ commit, dispatch }, note) => +export const deleteNote = ({ commit, dispatch, state }, note) => service.deleteNote(note.path).then(() => { + const discussion = state.discussions.find(({ id }) => id === note.discussion_id); + commit(types.DELETE_NOTE, note); dispatch('updateMergeRequestWidget'); + dispatch('updateResolvableDiscussonsCounts'); + + if (isInMRPage()) { + dispatch('diffs/removeDiscussionsFromDiff', discussion); + } }); export const updateNote = ({ commit, dispatch }, { endpoint, note }) => @@ -89,6 +97,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) => dispatch('updateMergeRequestWidget'); dispatch('startTaskList'); + dispatch('updateResolvableDiscussonsCounts'); } return res; }); @@ -104,6 +113,8 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, commit(mutationType, res); + dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateMergeRequestWidget'); }); @@ -385,5 +396,8 @@ export const startTaskList = ({ dispatch }) => }), ); +export const updateResolvableDiscussonsCounts = ({ commit }) => + commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 980d79605d7..2ed8aac059a 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -53,30 +53,15 @@ export const getCurrentUserLastNote = state => export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes).find(el => isLastNote(el, state)); -export const discussionCount = state => { - const filteredDiscussions = state.discussions.filter(n => !n.individual_note && n.resolvable); - - return filteredDiscussions.length; -}; - -export const unresolvedDiscussions = (state, getters) => { - const resolvedMap = getters.resolvedDiscussionsById; - - return state.discussions.filter(n => !n.individual_note && !resolvedMap[n.id]); -}; - -export const allDiscussions = (state, getters) => { - const resolved = getters.resolvedDiscussionsById; - const unresolved = getters.unresolvedDiscussions; - - return Object.values(resolved).concat(unresolved); -}; +export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCount; +export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount; +export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions; export const isDiscussionResolved = (state, getters) => discussionId => getters.resolvedDiscussionsById[discussionId] !== undefined; -export const allResolvableDiscussions = (state, getters) => - getters.allDiscussions.filter(d => !d.individual_note && d.resolvable); +export const allResolvableDiscussions = state => + state.discussions.filter(d => !d.individual_note && d.resolvable); export const resolvedDiscussionsById = state => { const map = {}; @@ -147,15 +132,12 @@ export const resolvedDiscussionCount = (state, getters) => { return Object.keys(resolvedMap).length; }; -export const discussionTabCounter = state => { - let all = []; - - state.discussions.forEach(discussion => { - all = all.concat(discussion.notes.filter(note => !note.system && !note.placeholder)); - }); - - return all.length; -}; +export const discussionTabCounter = state => + state.discussions.reduce( + (acc, discussion) => + acc + discussion.notes.filter(note => !note.system && !note.placeholder).length, + 0, + ); // Returns the list of discussion IDs ordered according to given parameter // @param {Boolean} diffOrder - is ordered by diff? @@ -182,8 +164,10 @@ export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, dif export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => { const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder); const currentIndex = idsOrdered.indexOf(discussionId); + const slicedIds = idsOrdered.slice(currentIndex + 1, currentIndex + 2); - return idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0]; + // Get the first ID if there is none after the currentIndex + return slicedIds.length ? idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0] : idsOrdered[0]; }; // @param {Boolean} diffOrder - is ordered by diff? diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 8aea269ea7d..b5fe8bdb1d3 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -22,6 +22,9 @@ export default () => ({ current_user: {}, }, commentsDisabled: false, + resolvableDiscussionsCount: 0, + unresolvedDiscussionsCount: 0, + hasUnresolvedDiscussions: false, }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index dfbf3b7b34b..9c68ab67a8c 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -21,6 +21,7 @@ export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; +export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index f6054e0be87..bea396e5bb6 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -24,6 +24,7 @@ export default { noteData.resolved = false; noteData.resolve_path = note.resolve_path; noteData.resolve_with_issue_path = note.resolve_with_issue_path; + noteData.diff_discussion = false; } state.discussions.push(noteData); @@ -97,33 +98,36 @@ export default { }, [types.SET_INITIAL_DISCUSSIONS](state, discussionsData) { - const discussions = []; + const discussions = discussionsData.reduce((acc, d) => { + const discussion = { ...d }; + const diffData = {}; - discussionsData.forEach(discussion => { if (discussion.diff_file) { - Object.assign(discussion, { - file_hash: discussion.diff_file.file_hash, - truncated_diff_lines: discussion.truncated_diff_lines || [], - }); + diffData.file_hash = discussion.diff_file.file_hash; + diffData.truncated_diff_lines = discussion.truncated_diff_lines || []; } // To support legacy notes, should be very rare case. if (discussion.individual_note && discussion.notes.length > 1) { discussion.notes.forEach(n => { - discussions.push({ + acc.push({ ...discussion, + ...diffData, notes: [n], // override notes array to only have one item to mimick individual_note }); }); } else { const oldNote = utils.findNoteObjectById(state.discussions, discussion.id); - discussions.push({ + acc.push({ ...discussion, + ...diffData, expanded: oldNote ? oldNote.expanded : discussion.expanded, }); } - }); + + return acc; + }, []); Object.assign(state, { discussions }); }, @@ -174,9 +178,11 @@ export default { } }, - [types.TOGGLE_DISCUSSION](state, { discussionId }) { + [types.TOGGLE_DISCUSSION](state, { discussionId, forceExpanded = null }) { const discussion = utils.findNoteObjectById(state.discussions, discussionId); - Object.assign(discussion, { expanded: !discussion.expanded }); + Object.assign(discussion, { + expanded: forceExpanded === null ? !discussion.expanded : forceExpanded, + }); }, [types.UPDATE_NOTE](state, note) { @@ -195,7 +201,9 @@ export default { const selectedDiscussion = state.discussions.find(disc => disc.id === note.id); note.expanded = true; // override expand flag to prevent collapse if (note.diff_file) { - Object.assign(note, { file_hash: note.diff_file.file_hash }); + Object.assign(note, { + file_hash: note.diff_file.file_hash, + }); } Object.assign(selectedDiscussion, { ...note }); }, @@ -229,4 +237,16 @@ export default { [types.DISABLE_COMMENTS](state, value) { state.commentsDisabled = value; }, + [types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS](state) { + state.resolvableDiscussionsCount = state.discussions.filter( + discussion => !discussion.individual_note && discussion.resolvable, + ).length; + state.unresolvedDiscussionsCount = state.discussions.filter( + discussion => + !discussion.individual_note && + discussion.resolvable && + discussion.notes.some(note => !note.resolved), + ).length; + state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1; + }, }; diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js index c4c8cf86cb0..e7fa05faa8a 100644 --- a/app/assets/javascripts/notifications_dropdown.js +++ b/app/assets/javascripts/notifications_dropdown.js @@ -12,6 +12,10 @@ export default function notificationsDropdown() { const form = $(this).parents('.notification-form:first'); form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner'); + if (form.hasClass('no-label')) { + form.find('.js-notification-loading').toggleClass('hidden'); + form.find('.js-notifications-icon').toggleClass('hidden'); + } form.find('#notification_setting_level').val(notificationLevel); form.submit(); }); diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js index 21efc4f6d00..845a5f7042c 100644 --- a/app/assets/javascripts/pages/groups/clusters/index/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index/index.js @@ -1,7 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; document.addEventListener('DOMContentLoaded', () => { - const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + initDismissableCallout('.gcp-signup-offer'); }); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 32b55575f95..01ef445c901 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -5,6 +5,7 @@ import initSettingsPanels from '~/settings_panels'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import { GROUP_BADGE } from '~/badges/constants'; +import groupsSelect from '~/groups_select'; import projectSelect from '~/project_select'; document.addEventListener('DOMContentLoaded', () => { @@ -17,5 +18,8 @@ document.addEventListener('DOMContentLoaded', () => { ); mountBadgeSettings(GROUP_BADGE); + // Initialize Subgroups selector + groupsSelect(); + projectSelect(); }); diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js index 00e2d7fc998..bf80d8b8193 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/index.js @@ -1,12 +1,6 @@ -import PersistentUserCallout from '~/persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; -function initCallout() { - const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new -} - document.addEventListener('DOMContentLoaded', () => { const { page } = document.body.dataset; const newClusterViews = [ @@ -16,7 +10,7 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - initCallout(); + initDismissableCallout('.gcp-signup-offer'); initGkeDropdowns(); } }); diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js index 21efc4f6d00..845a5f7042c 100644 --- a/app/assets/javascripts/pages/projects/clusters/index/index.js +++ b/app/assets/javascripts/pages/projects/clusters/index/index.js @@ -1,7 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; document.addEventListener('DOMContentLoaded', () => { - const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + initDismissableCallout('.gcp-signup-offer'); }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index f5b1cf85e68..899d5925956 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -3,8 +3,8 @@ import initSettingsPanels from '~/settings_panels'; import setupProjectEdit from '~/project_edit'; import initConfirmDangerModal from '~/confirm_danger_modal'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; +import fileUpload from '~/lib/utils/file_upload'; import initProjectLoadingSpinner from '../shared/save_project_loader'; -import projectAvatar from '../shared/project_avatar'; import initProjectPermissionsSettings from '../shared/permissions'; document.addEventListener('DOMContentLoaded', () => { @@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { setupProjectEdit(); // Initialize expandable settings panels initSettingsPanels(); - projectAvatar(); + fileUpload('.js-choose-project-avatar-button', '.js-project-avatar-input'); initProjectPermissionsSettings(); initConfirmDangerModal(); mountBadgeSettings(PROJECT_BADGE); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index b0345b4e50d..5659e13981a 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,5 +1,5 @@ +import initDismissableCallout from '~/dismissable_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; -import PersistentUserCallout from '../../persistent_user_callout'; import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; @@ -12,9 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - const callout = document.querySelector('.gcp-signup-offer'); - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new - + initDismissableCallout('.gcp-signup-offer'); initGkeDropdowns(); } diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index a6bee49a6b1..b288989b252 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -13,6 +13,9 @@ export default class Project { const $cloneOptions = $('ul.clone-options-dropdown'); const $projectCloneField = $('#project_clone'); const $cloneBtnLabel = $('.js-git-clone-holder .js-clone-dropdown-label'); + const mobileCloneField = document.querySelector( + '.js-mobile-git-clone .js-clone-dropdown-label', + ); const selectedCloneOption = $cloneBtnLabel.text().trim(); if (selectedCloneOption.length > 0) { @@ -36,7 +39,11 @@ export default class Project { $label.text(activeText); }); - $projectCloneField.val(url); + if (mobileCloneField) { + mobileCloneField.dataset.clipboardText = url; + } else { + $projectCloneField.val(url); + } $('.js-git-empty .js-clone').text(url); }); // Ref switcher diff --git a/app/assets/javascripts/pages/projects/serverless/index.js b/app/assets/javascripts/pages/projects/serverless/index.js new file mode 100644 index 00000000000..7b08620773c --- /dev/null +++ b/app/assets/javascripts/pages/projects/serverless/index.js @@ -0,0 +1,5 @@ +import ServerlessBundle from '~/serverless/serverless_bundle'; + +document.addEventListener('DOMContentLoaded', () => { + new ServerlessBundle(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js index a52861c9efa..3e02893f24c 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/form.js +++ b/app/assets/javascripts/pages/projects/settings/repository/form.js @@ -7,6 +7,7 @@ import initDeployKeys from '~/deploy_keys'; import ProtectedBranchCreate from '~/protected_branches/protected_branch_create'; import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list'; import DueDateSelectors from '~/due_date_select'; +import fileUpload from '~/lib/utils/file_upload'; export default () => { new ProtectedTagCreate(); @@ -16,4 +17,5 @@ export default () => { new ProtectedBranchCreate(); new ProtectedBranchEditList(); new DueDateSelectors(); + fileUpload('.js-choose-file', '.js-object-map-input'); }; diff --git a/app/assets/javascripts/pages/projects/shared/project_avatar.js b/app/assets/javascripts/pages/projects/shared/project_avatar.js deleted file mode 100644 index 1e69ecb481d..00000000000 --- a/app/assets/javascripts/pages/projects/shared/project_avatar.js +++ /dev/null @@ -1,16 +0,0 @@ -import $ from 'jquery'; - -export default function projectAvatar() { - $('.js-choose-project-avatar-button').bind('click', function onClickAvatar() { - const form = $(this).closest('form'); - return form.find('.js-project-avatar-input').click(); - }); - - $('.js-project-avatar-input').bind('change', function onClickAvatarInput() { - const form = $(this).closest('form'); - const filename = $(this) - .val() - .replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape - return form.find('.js-avatar-filename').text(filename); - }); -} diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index d3e8dbf4000..9b58d42b47d 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -1,5 +1,4 @@ import bp from '../../../breakpoints'; -import { slugify } from '../../../lib/utils/text_utility'; import { parseQueryStringIntoObject } from '../../../lib/utils/common_utils'; import { mergeUrlParams, redirectTo } from '../../../lib/utils/url_utility'; @@ -26,7 +25,8 @@ export default class Wikis { if (!this.newWikiForm) return; const slugInput = this.newWikiForm.querySelector('#new_wiki_path'); - const slug = slugify(slugInput.value); + + const slug = slugInput.value; if (slug.length > 0) { const wikisPath = slugInput.getAttribute('data-wikis-path'); diff --git a/app/assets/javascripts/pages/root/index.js b/app/assets/javascripts/pages/root/index.js deleted file mode 100644 index 09f8185d3b5..00000000000 --- a/app/assets/javascripts/pages/root/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// if the "projects dashboard" is a user's default dashboard, when they visit the -// instance root index, the dashboard will be served by the root controller instead -// of a dashboard controller. The root index redirects for all other default dashboards. - -import '../dashboard/projects/index'; diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js deleted file mode 100644 index 1e34e74a152..00000000000 --- a/app/assets/javascripts/persistent_user_callout.js +++ /dev/null @@ -1,34 +0,0 @@ -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; -import Flash from './flash'; - -export default class PersistentUserCallout { - constructor(container) { - const { dismissEndpoint, featureId } = container.dataset; - this.container = container; - this.dismissEndpoint = dismissEndpoint; - this.featureId = featureId; - - this.init(); - } - - init() { - const closeButton = this.container.querySelector('.js-close'); - closeButton.addEventListener('click', event => this.dismiss(event)); - } - - dismiss(event) { - event.preventDefault(); - - axios - .post(this.dismissEndpoint, { - feature_name: this.featureId, - }) - .then(() => { - this.container.remove(); - }) - .catch(() => { - Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); - }); - } -} diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 6f008528db4..59cebaba717 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -18,23 +18,19 @@ export default { required: true, }, }, - computed: { graph() { return this.pipeline.details && this.pipeline.details.stages; }, }, - methods: { capitalizeStageName(name) { const escapedName = _.escape(name); return escapedName.charAt(0).toUpperCase() + escapedName.slice(1); }, - isFirstColumn(index) { return index === 0; }, - stageConnectorClass(index, stage) { let className; @@ -48,7 +44,6 @@ export default { return className; }, - refreshPipelineGraph() { this.$emit('refreshPipelineGraph'); }, diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 782494f72e4..cf9db89e32b 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -84,10 +84,6 @@ export default { return textBuilder.join(' '); }, - - tooltipBoundary() { - return this.dropdownLength < 5 ? 'viewport' : null; - }, /** * Verifies if the provided job has an action path * @@ -108,7 +104,7 @@ export default { <div class="ci-job-component"> <gl-link v-if="status.has_details" - v-gl-tooltip="{ boundary: tooltipBoundary }" + v-gl-tooltip :href="status.details_path" :title="tooltipText" :class="cssClassJobName" diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index e5924d3a77e..30a5bbf92ce 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -65,7 +65,7 @@ export default { v-if="pipeline.flags.latest" v-gl-tooltip class="js-pipeline-url-latest badge badge-success" - title="Latest pipeline for this branch" + title="__('Latest pipeline for this branch')" > latest </span> @@ -97,6 +97,14 @@ export default { <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning"> stuck </span> + <span + v-if="pipeline.flags.merge_request" + v-gl-tooltip + title="__('This pipeline is run in a merge request context')" + class="js-pipeline-url-mergerequest badge badge-info" + > + merge request + </span> </div> </div> </template> diff --git a/app/assets/javascripts/serverless/components/empty_state.vue b/app/assets/javascripts/serverless/components/empty_state.vue new file mode 100644 index 00000000000..2683805f2f7 --- /dev/null +++ b/app/assets/javascripts/serverless/components/empty_state.vue @@ -0,0 +1,40 @@ +<script> +export default { + props: { + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="row empty-state js-empty-state"> + <div class="col-12"> + <div class="text-content"> + <h4 class="state-title text-center"> + {{ s__('Serverless|Getting started with serverless') }} + </h4> + <p class="state-description"> + {{ + s__(`Serverless| In order to start using functions as a service, + you must first install Knative on your Kubernetes cluster.`) + }} + + <a :href="helpPath"> {{ __('More information') }} </a> + </p> + + <div class="text-center"> + <a :href="clustersPath" class="btn btn-success"> + {{ s__('Serverless|Install Knative') }} + </a> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue new file mode 100644 index 00000000000..31f5427c771 --- /dev/null +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -0,0 +1,40 @@ +<script> +import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + Timeago, + }, + props: { + func: { + type: Object, + required: true, + }, + }, + computed: { + name() { + return this.func.name; + }, + url() { + return this.func.url; + }, + image() { + return this.func.image; + }, + timestamp() { + return this.func.created_at; + }, + }, +}; +</script> + +<template> + <div class="gl-responsive-table-row"> + <div class="table-section section-20">{{ name }}</div> + <div class="table-section section-50"> + <a :href="url">{{ url }}</a> + </div> + <div class="table-section section-20">{{ image }}</div> + <div class="table-section section-10"><timeago :time="timestamp" /></div> + </div> +</template> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue new file mode 100644 index 00000000000..7874a7b6b6a --- /dev/null +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -0,0 +1,123 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import FunctionRow from './function_row.vue'; +import EmptyState from './empty_state.vue'; + +export default { + components: { + FunctionRow, + EmptyState, + GlSkeletonLoading, + }, + props: { + functions: { + type: Array, + required: true, + default: () => [], + }, + installed: { + type: Boolean, + required: true, + }, + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + loadingData: { + type: Boolean, + required: false, + default: true, + }, + hasFunctionData: { + type: Boolean, + required: false, + default: true, + }, + }, +}; +</script> + +<template> + <section id="serverless-functions"> + <div v-if="installed"> + <div v-if="hasFunctionData"> + <div class="ci-table js-services-list function-element"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-20" role="rowheader"> + {{ s__('Serverless|Function') }} + </div> + <div class="table-section section-50" role="rowheader"> + {{ s__('Serverless|Domain') }} + </div> + <div class="table-section section-20" role="rowheader"> + {{ s__('Serverless|Runtime') }} + </div> + <div class="table-section section-10" role="rowheader"> + {{ s__('Serverless|Last Update') }} + </div> + </div> + <template v-if="loadingData"> + <div v-for="j in 3" :key="j" class="gl-responsive-table-row"> + <gl-skeleton-loading /> + </div> + </template> + <template v-else> + <function-row v-for="f in functions" :key="f.name" :func="f" /> + </template> + </div> + </div> + <div v-else class="empty-state js-empty-state"> + <div class="text-content"> + <h4 class="state-title text-center">{{ s__('Serverless|No functions available') }}</h4> + <p class="state-description"> + {{ + s__(`Serverless|There is currently no function data available from Knative. + This could be for a variety of reasons including:`) + }} + </p> + <ul> + <li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li> + <li>Your <code>gitlab-ci.yml</code> file is not properly configured.</li> + <li> + The functions listed in the <code>serverless.yml</code> file don't match the namespace + of your cluster. + </li> + <li>The deploy job has not finished.</li> + </ul> + + <p> + {{ + s__(`Serverless|If you believe none of these apply, please check + back later as the function data may be in the process of becoming + available.`) + }} + </p> + <div class="text-center"> + <a :href="helpPath" class="btn btn-success"> + {{ s__('Serverless|Learn more about Serverless') }} + </a> + </div> + </div> + </div> + </div> + + <empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" /> + </section> +</template> + +<style> +.top-area { + border-bottom: 0; +} + +.function-element { + border-bottom: 1px solid #e5e5e5; + border-bottom-color: rgb(229, 229, 229); + border-bottom-style: solid; + border-bottom-width: 1px; +} +</style> diff --git a/app/assets/javascripts/serverless/event_hub.js b/app/assets/javascripts/serverless/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/serverless/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js new file mode 100644 index 00000000000..3e3b81ba247 --- /dev/null +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -0,0 +1,106 @@ +import Visibility from 'visibilityjs'; +import Vue from 'vue'; +import { s__ } from '../locale'; +import Flash from '../flash'; +import Poll from '../lib/utils/poll'; +import ServerlessStore from './stores/serverless_store'; +import GetFunctionsService from './services/get_functions_service'; +import Functions from './components/functions.vue'; + +export default class Serverless { + constructor() { + const { statusPath, clustersPath, helpPath, installed } = document.querySelector( + '.js-serverless-functions-page', + ).dataset; + + this.service = new GetFunctionsService(statusPath); + this.knativeInstalled = installed !== undefined; + this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath); + this.initServerless(); + this.functionLoadCount = 0; + + if (statusPath && this.knativeInstalled) { + this.initPolling(); + } + } + + initServerless() { + const { store } = this; + const el = document.querySelector('#js-serverless-functions'); + + this.functions = new Vue({ + el, + data() { + return { + state: store.state, + }; + }, + render(createElement) { + return createElement(Functions, { + props: { + functions: this.state.functions, + installed: this.state.installed, + clustersPath: this.state.clustersPath, + helpPath: this.state.helpPath, + loadingData: this.state.loadingData, + hasFunctionData: this.state.hasFunctionData, + }, + }); + }, + }); + } + + initPolling() { + this.poll = new Poll({ + resource: this.service, + method: 'fetchData', + successCallback: data => this.handleSuccess(data), + errorCallback: () => this.handleError(), + }); + + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } else { + this.service + .fetchData() + .then(data => this.handleSuccess(data)) + .catch(() => this.handleError()); + } + + Visibility.change(() => { + if (!Visibility.hidden() && !this.destroyed) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + } + + handleSuccess(data) { + if (data.status === 200) { + this.store.updateFunctionsFromServer(data.data); + this.store.updateLoadingState(false); + } else if (data.status === 204) { + /* Time out after 3 attempts to retrieve data */ + this.functionLoadCount += 1; + if (this.functionLoadCount === 3) { + this.poll.stop(); + this.store.toggleNoFunctionData(); + } + } + } + + static handleError() { + Flash(s__('Serverless|An error occurred while retrieving serverless components')); + } + + destroy() { + this.destroyed = true; + + if (this.poll) { + this.poll.stop(); + } + + this.functions.$destroy(); + } +} diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js new file mode 100644 index 00000000000..303b42dc66c --- /dev/null +++ b/app/assets/javascripts/serverless/services/get_functions_service.js @@ -0,0 +1,11 @@ +import axios from '~/lib/utils/axios_utils'; + +export default class GetFunctionsService { + constructor(endpoint) { + this.endpoint = endpoint; + } + + fetchData() { + return axios.get(this.endpoint); + } +} diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js new file mode 100644 index 00000000000..774c15b5b12 --- /dev/null +++ b/app/assets/javascripts/serverless/stores/serverless_store.js @@ -0,0 +1,24 @@ +export default class ServerlessStore { + constructor(knativeInstalled = false, clustersPath, helpPath) { + this.state = { + functions: [], + hasFunctionData: true, + loadingData: true, + installed: knativeInstalled, + clustersPath, + helpPath, + }; + } + + updateFunctionsFromServer(functions = []) { + this.state.functions = functions; + } + + updateLoadingState(loadingData) { + this.state.loadingData = loadingData; + } + + toggleNoFunctionData() { + this.state.hasFunctionData = false; + } +} diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js index 007b83e1927..9af5d5b23cb 100644 --- a/app/assets/javascripts/star.js +++ b/app/assets/javascripts/star.js @@ -23,11 +23,11 @@ export default class Star { if (isStarred) { $starSpan.removeClass('starred').text(s__('StarProject|Star')); $startIcon.remove(); - $this.prepend(spriteIcon('star-o')); + $this.prepend(spriteIcon('star-o', 'icon')); } else { $starSpan.addClass('starred').text(__('Unstar')); $startIcon.remove(); - $this.prepend(spriteIcon('star')); + $this.prepend(spriteIcon('star', 'icon')); } }) .catch(() => Flash('Star toggle failed. Try again later.')); diff --git a/app/assets/javascripts/terminal/index.js b/app/assets/javascripts/terminal/index.js index 49aeb377c74..8faff59fd45 100644 --- a/app/assets/javascripts/terminal/index.js +++ b/app/assets/javascripts/terminal/index.js @@ -1,3 +1,3 @@ import Terminal from './terminal'; -export default () => new Terminal({ selector: '#terminal' }); +export default () => new Terminal(document.getElementById('terminal')); diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js index b24aa8a3a34..560f50ebf8f 100644 --- a/app/assets/javascripts/terminal/terminal.js +++ b/app/assets/javascripts/terminal/terminal.js @@ -1,9 +1,15 @@ +import _ from 'underscore'; import $ from 'jquery'; import { Terminal } from 'xterm'; import * as fit from 'xterm/lib/addons/fit/fit'; +import { canScrollUp, canScrollDown } from '~/lib/utils/dom_utils'; + +const SCROLL_MARGIN = 5; + +Terminal.applyAddon(fit); export default class GLTerminal { - constructor(options = {}) { + constructor(element, options = {}) { this.options = Object.assign( {}, { @@ -13,7 +19,8 @@ export default class GLTerminal { options, ); - this.container = document.querySelector(options.selector); + this.container = element; + this.onDispose = []; this.setSocketUrl(); this.createTerminal(); @@ -34,8 +41,6 @@ export default class GLTerminal { } createTerminal() { - Terminal.applyAddon(fit); - this.terminal = new Terminal(this.options); this.socket = new WebSocket(this.socketUrl, ['terminal.gitlab.com']); @@ -72,4 +77,48 @@ export default class GLTerminal { handleSocketFailure() { this.terminal.write('\r\nConnection failure'); } + + addScrollListener(onScrollLimit) { + const viewport = this.container.querySelector('.xterm-viewport'); + const listener = _.throttle(() => { + onScrollLimit({ + canScrollUp: canScrollUp(viewport, SCROLL_MARGIN), + canScrollDown: canScrollDown(viewport, SCROLL_MARGIN), + }); + }); + + this.onDispose.push(() => viewport.removeEventListener('scroll', listener)); + viewport.addEventListener('scroll', listener); + + // don't forget to initialize value before scroll! + listener({ target: viewport }); + } + + disable() { + this.terminal.setOption('cursorBlink', false); + this.terminal.setOption('theme', { foreground: '#707070' }); + this.terminal.setOption('disableStdin', true); + this.socket.close(); + } + + dispose() { + this.terminal.off('data'); + this.terminal.dispose(); + this.socket.close(); + + this.onDispose.forEach(fn => fn()); + this.onDispose.length = 0; + } + + scrollToTop() { + this.terminal.scrollToTop(); + } + + scrollToBottom() { + this.terminal.scrollToBottom(); + } + + fit() { + this.terminal.fit(); + } } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 950347d8863..2f2a37347af 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -112,7 +112,7 @@ export default { </script> <template> - <div class="mr-widget-heading deploy-heading append-bottom-default"> + <div class="deploy-heading"> <div class="ci-widget media"> <div class="media-body"> <div class="deploy-body"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue new file mode 100644 index 00000000000..5967ca026e5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue @@ -0,0 +1,6 @@ +<template> + <div class="mr-widget-heading"> + <div class="mr-widget-content"><slot name="default"></slot></div> + <slot name="footer"></slot> + </div> +</template> 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 6f422ea3f27..3b9fc2661ef 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 @@ -6,6 +6,7 @@ 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 TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import MrWidgetIcon from './mr_widget_icon.vue'; export default { name: 'MRWidgetHeader', @@ -13,6 +14,7 @@ export default { Icon, clipboardButton, TooltipOnTruncate, + MrWidgetIcon, }, directives: { tooltip, @@ -76,7 +78,7 @@ export default { </script> <template> <div class="mr-source-target append-bottom-default"> - <div class="git-merge-icon-container append-right-default"><icon name="git-merge" /></div> + <mr-widget-icon name="git-merge" /> <div class="git-merge-container d-flex"> <div class="normal"> <strong> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue new file mode 100644 index 00000000000..e3adc7f7af5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue @@ -0,0 +1,17 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { Icon }, + props: { + name: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="circle-icon-container append-right-default"><icon :name="name" /></div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 71571ba9cab..f11cf21b0ca 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -79,67 +79,65 @@ export default { </script> <template> - <div v-if="hasPipeline || hasCIError" class="mr-widget-heading append-bottom-default"> - <div class="ci-widget media"> - <template v-if="hasCIError"> - <div - class="add-border ci-status-icon ci-status-icon-failed ci-error - js-ci-error append-right-default" - > - <icon :size="32" name="status_failed_borderless" /> - </div> - <div class="media-body" v-html="errorText"></div> - </template> - <template v-else-if="hasPipeline"> - <a :href="status.details_path" class="align-self-start append-right-default"> - <ci-icon :status="status" :size="32" :borderless="true" class="add-border" /> - </a> - <div class="ci-widget-container d-flex"> - <div class="ci-widget-content"> - <div class="media-body"> - <div class="font-weight-bold"> - Pipeline - <a :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number" - >#{{ pipeline.id }}</a - > + <div v-if="hasPipeline || hasCIError" class="ci-widget media"> + <template v-if="hasCIError"> + <div + class="add-border ci-status-icon ci-status-icon-failed ci-error + js-ci-error append-right-default" + > + <icon :size="32" name="status_failed_borderless" /> + </div> + <div class="media-body" v-html="errorText"></div> + </template> + <template v-else-if="hasPipeline"> + <a :href="status.details_path" class="align-self-start append-right-default"> + <ci-icon :status="status" :size="32" :borderless="true" class="add-border" /> + </a> + <div class="ci-widget-container d-flex"> + <div class="ci-widget-content"> + <div class="media-body"> + <div class="font-weight-bold"> + Pipeline + <a :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number" + >#{{ pipeline.id }}</a + > - {{ pipeline.details.status.label }} + {{ pipeline.details.status.label }} - <template v-if="hasCommitInfo"> - for - <a - :href="pipeline.commit.commit_path" - class="commit-sha js-commit-link font-weight-normal" - > - {{ pipeline.commit.short_id }}</a - > - on - <tooltip-on-truncate - :title="sourceBranch" - truncate-target="child" - class="label-branch label-truncate" - v-html="sourceBranchLink" - /> - </template> - </div> - <div v-if="pipeline.coverage" class="coverage">Coverage {{ pipeline.coverage }}%</div> + <template v-if="hasCommitInfo"> + for + <a + :href="pipeline.commit.commit_path" + class="commit-sha js-commit-link font-weight-normal" + > + {{ pipeline.commit.short_id }}</a + > + on + <tooltip-on-truncate + :title="sourceBranch" + truncate-target="child" + class="label-branch label-truncate" + v-html="sourceBranchLink" + /> + </template> </div> + <div v-if="pipeline.coverage" class="coverage">Coverage {{ pipeline.coverage }}%</div> </div> - <div> - <span class="mr-widget-pipeline-graph"> - <span v-if="hasStages" class="stage-cell"> - <div - v-for="(stage, i) in pipeline.details.stages" - :key="i" - class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" - > - <pipeline-stage :stage="stage" /> - </div> - </span> + </div> + <div> + <span class="mr-widget-pipeline-graph"> + <span v-if="hasStages" class="stage-cell"> + <div + v-for="(stage, i) in pipeline.details.stages" + :key="i" + class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" + > + <pipeline-stage :stage="stage" /> + </div> </span> - </div> + </span> </div> - </template> - </div> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue new file mode 100644 index 00000000000..5f5fe67b3c1 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -0,0 +1,74 @@ +<script> +import Deployment from './deployment.vue'; +import MrWidgetContainer from './mr_widget_container.vue'; +import MrWidgetPipeline from './mr_widget_pipeline.vue'; + +/** + * Renders the pipeline and related deployments from the store. + * + * | Props | Description + * |---------------|------------- + * | `mr` | This is the mr_widget store + * | `isPostMerge` | If true, show the "post merge" pipeline and deployments + */ +export default { + name: 'MrWidgetPipelineContainer', + components: { + Deployment, + MrWidgetContainer, + MrWidgetPipeline, + }, + props: { + mr: { + type: Object, + required: true, + }, + isPostMerge: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + pipeline() { + return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline; + }, + branch() { + return this.isPostMerge ? this.mr.targetBranch : this.mr.sourceBranch; + }, + branchLink() { + return this.isPostMerge ? this.mr.targetBranch : this.mr.sourceBranchLink; + }, + deployments() { + return this.isPostMerge ? this.mr.postMergeDeployments : this.mr.deployments; + }, + deploymentClass() { + return this.isPostMerge ? 'js-post-deployment' : 'js-pre-deployment'; + }, + hasDeploymentMetrics() { + return this.isPostMerge; + }, + }, +}; +</script> +<template> + <mr-widget-container> + <mr-widget-pipeline + :pipeline="pipeline" + :ci-status="mr.ciStatus" + :has-ci="mr.hasCI" + :source-branch="branch" + :source-branch-link="branchLink" + :troubleshooting-docs-path="mr.troubleshootingDocsPath" + /> + <div v-if="deployments.length" slot="footer" class="mr-widget-extension"> + <deployment + v-for="deployment in deployments" + :key="deployment.id" + :class="deploymentClass" + :deployment="deployment" + :show-metrics="hasDeploymentMetrics" + /> + </div> + </mr-widget-container> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index a269c0a4e87..3c3e3efcc36 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -6,7 +6,7 @@ import SmartInterval from '~/smart_interval'; import createFlash from '../flash'; import WidgetHeader from './components/mr_widget_header.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; -import WidgetPipeline from './components/mr_widget_pipeline.vue'; +import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; import Deployment from './components/deployment.vue'; import WidgetRelatedLinks from './components/mr_widget_related_links.vue'; import MergedState from './components/states/mr_widget_merged.vue'; @@ -44,7 +44,7 @@ export default { components: { 'mr-widget-header': WidgetHeader, 'mr-widget-merge-help': WidgetMergeHelp, - 'mr-widget-pipeline': WidgetPipeline, + MrWidgetPipelineContainer, Deployment, 'mr-widget-related-links': WidgetRelatedLinks, 'mr-widget-merged': MergedState, @@ -296,23 +296,12 @@ export default { <template> <div class="mr-state-widget prepend-top-default"> <mr-widget-header :mr="mr" /> - <mr-widget-pipeline + <mr-widget-pipeline-container v-if="shouldRenderPipelines" - :pipeline="mr.pipeline" - :ci-status="mr.ciStatus" - :has-ci="mr.hasCI" - :source-branch="mr.sourceBranch" - :source-branch-link="mr.sourceBranchLink" - :troubleshooting-docs-path="mr.troubleshootingDocsPath" + class="mr-widget-workflow" + :mr="mr" /> - <deployment - v-for="deployment in mr.deployments" - :key="`pre-merge-deploy-${deployment.id}`" - class="js-pre-merge-deploy" - :deployment="deployment" - :show-metrics="false" - /> - <div class="mr-section-container"> + <div class="mr-section-container mr-widget-workflow"> <grouped-test-reports-app v-if="mr.testResultsPath" class="js-reports-container" @@ -336,24 +325,11 @@ export default { </div> <div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div> </div> - - <template v-if="shouldRenderMergedPipeline"> - <mr-widget-pipeline - class="js-post-merge-pipeline prepend-top-default" - :pipeline="mr.mergePipeline" - :ci-status="mr.ciStatus" - :has-ci="mr.hasCI" - :source-branch="mr.targetBranch" - :source-branch-link="mr.targetBranch" - :troubleshooting-docs-path="mr.troubleshootingDocsPath" - /> - <deployment - v-for="postMergeDeployment in mr.postMergeDeployments" - :key="`post-merge-deploy-${postMergeDeployment.id}`" - :deployment="postMergeDeployment" - :show-metrics="true" - class="js-post-deployment" - /> - </template> + <mr-widget-pipeline-container + v-if="shouldRenderMergedPipeline" + class="js-post-merge-pipeline mr-widget-workflow" + :mr="mr" + :is-post-merge="true" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index bb2e0e12c11..75c66ed850b 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -1,7 +1,10 @@ <script> +import { diffModes } from '~/ide/constants'; import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils'; import ImageDiffViewer from './viewers/image_diff_viewer.vue'; import DownloadDiffViewer from './viewers/download_diff_viewer.vue'; +import RenamedFile from './viewers/renamed.vue'; +import ModeChanged from './viewers/mode_changed.vue'; export default { props: { @@ -30,9 +33,25 @@ export default { required: false, default: '', }, + aMode: { + type: String, + required: false, + default: null, + }, + bMode: { + type: String, + required: false, + default: null, + }, }, computed: { viewer() { + if (this.diffMode === diffModes.renamed) { + return RenamedFile; + } else if (this.diffMode === diffModes.mode_changed) { + return ModeChanged; + } + if (!this.newPath) return null; const previewInfo = viewerInformationForPath(this.newPath); @@ -67,8 +86,10 @@ export default { :new-path="fullNewPath" :old-path="fullOldPath" :project-path="projectPath" + :a-mode="aMode" + :b-mode="bMode" > - <slot slot="image-overlay" name="image-overlay"> </slot> + <slot slot="image-overlay" name="image-overlay"></slot> </component> <slot></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue new file mode 100644 index 00000000000..3c7a4ea6183 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue @@ -0,0 +1,30 @@ +<script> +import { sprintf, __ } from '~/locale'; + +export default { + props: { + aMode: { + type: String, + required: false, + default: null, + }, + bMode: { + type: String, + required: false, + default: null, + }, + }, + computed: { + outputText() { + return sprintf(__('File mode changed from %{a_mode} to %{b_mode}'), { + a_mode: this.aMode, + b_mode: this.bMode, + }); + }, + }, +}; +</script> + +<template> + <div class="nothing-here-block">{{ outputText }}</div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue new file mode 100644 index 00000000000..5c1ea59b471 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue @@ -0,0 +1,3 @@ +<template> + <div class="nothing-here-block">{{ __('File moved') }}</div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index b1faebf409b..8d3a3009c55 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -17,12 +17,14 @@ * /> */ import { mapGetters } from 'vuex'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import userAvatarLink from '../user_avatar/user_avatar_link.vue'; export default { name: 'PlaceholderNote', components: { userAvatarLink, + TimelineEntryItem, }, props: { note: { @@ -37,30 +39,28 @@ export default { </script> <template> - <li class="note being-posted fade-in-half timeline-entry"> - <div class="timeline-entry-inner"> - <div class="timeline-icon"> - <user-avatar-link - :link-href="getUserData.path" - :img-src="getUserData.avatar_url" - :img-size="40" - /> - </div> - <div :class="{ discussion: !note.individual_note }" class="timeline-content"> - <div class="note-header"> - <div class="note-header-info"> - <a :href="getUserData.path"> - <span class="d-none d-sm-inline-block">{{ getUserData.name }}</span> - <span class="note-headline-light">@{{ getUserData.username }}</span> - </a> - </div> + <timeline-entry-item class="note being-posted fade-in-half"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="getUserData.path" + :img-src="getUserData.avatar_url" + :img-size="40" + /> + </div> + <div :class="{ discussion: !note.individual_note }" class="timeline-content"> + <div class="note-header"> + <div class="note-header-info"> + <a :href="getUserData.path"> + <span class="d-none d-sm-inline-block">{{ getUserData.name }}</span> + <span class="note-headline-light">@{{ getUserData.username }}</span> + </a> </div> - <div class="note-body"> - <div class="note-text"> - <p>{{ note.body }}</p> - </div> + </div> + <div class="note-body"> + <div class="note-text"> + <p>{{ note.body }}</p> </div> </div> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue index 674f923478d..7689425eb52 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue @@ -1,4 +1,6 @@ <script> +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + /** * Common component to render a placeholder system note. * @@ -9,6 +11,9 @@ */ export default { name: 'PlaceholderSystemNote', + components: { + TimelineEntryItem, + }, props: { note: { type: Object, @@ -19,11 +24,9 @@ export default { </script> <template> - <li class="note system-note timeline-entry being-posted fade-in-half"> - <div class="timeline-entry-inner"> - <div class="timeline-content"> - <em>{{ note.body }}</em> - </div> + <timeline-entry-item class="note system-note being-posted fade-in-half"> + <div class="timeline-content"> + <em>{{ note.body }}</em> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index c6cf4661222..e61d1fd2031 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -1,22 +1,22 @@ <script> import { GlSkeletonLoading } from '@gitlab/ui'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; export default { name: 'SkeletonNote', components: { GlSkeletonLoading, + TimelineEntryItem, }, }; </script> <template> - <li class="timeline-entry note note-wrapper"> - <div class="timeline-entry-inner"> - <div class="timeline-icon"></div> - <div class="timeline-content"> - <div class="note-header"></div> - <div class="note-body"><gl-skeleton-loading /></div> - </div> + <timeline-entry-item class="note note-wrapper"> + <div class="timeline-icon"></div> + <div class="timeline-content"> + <div class="note-header"></div> + <div class="note-body"><gl-skeleton-loading /></div> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index fb86262d0b4..31df26f7b05 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -20,6 +20,7 @@ import $ from 'jquery'; import { mapGetters } from 'vuex'; import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; @@ -29,6 +30,7 @@ export default { components: { Icon, noteHeader, + TimelineEntryItem, }, props: { note: { @@ -73,36 +75,34 @@ export default { </script> <template> - <li + <timeline-entry-item :id="noteAnchorId" :class="{ target: isTargetNote }" - class="note system-note timeline-entry note-wrapper" + class="note system-note note-wrapper" > - <div class="timeline-entry-inner"> - <div class="timeline-icon" v-html="iconHtml"></div> - <div class="timeline-content"> - <div class="note-header"> - <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> - <span v-html="actionTextHtml"></span> - </note-header> - </div> - <div class="note-body"> - <div - :class="{ - 'system-note-commit-list': hasMoreCommits, - 'hide-shade': expanded, - }" - class="note-text" - v-html="note.note_html" - ></div> - <div v-if="hasMoreCommits" class="flex-list"> - <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded;"> - <icon :name="toggleIcon" :size="8" class="append-right-5" /> - <span>Toggle commit list</span> - </div> + <div class="timeline-icon" v-html="iconHtml"></div> + <div class="timeline-content"> + <div class="note-header"> + <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> + <span v-html="actionTextHtml"></span> + </note-header> + </div> + <div class="note-body"> + <div + :class="{ + 'system-note-commit-list': hasMoreCommits, + 'hide-shade': expanded, + }" + class="note-text" + v-html="note.note_html" + ></div> + <div v-if="hasMoreCommits" class="flex-list"> + <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded;"> + <icon :name="toggleIcon" :size="8" class="append-right-5" /> + <span>Toggle commit list</span> </div> </div> </div> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue new file mode 100644 index 00000000000..06974a12aed --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue @@ -0,0 +1,11 @@ +<script> +export default { + name: 'TimelineEntryItem', +}; +</script> + +<template> + <li class="timeline-entry"> + <div class="timeline-entry-inner"><slot></slot></div> + </li> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue index e742900dbcb..373794fb1f2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue @@ -44,6 +44,7 @@ export default { class="sidebar-collapsed-icon" data-placement="left" data-container="body" + data-boundary="viewport" @click="handleClick" > <i aria-hidden="true" data-hidden="true" class="fa fa-tags"> </i> diff --git a/app/assets/stylesheets/bootstrap.scss b/app/assets/stylesheets/bootstrap.scss index a040c2f8c20..4a09da3d580 100644 --- a/app/assets/stylesheets/bootstrap.scss +++ b/app/assets/stylesheets/bootstrap.scss @@ -1,5 +1,5 @@ /* - * Includes specific styles from the bootstrap4 foler in node_modules + * Includes specific styles from the bootstrap4 folder in node_modules */ @import "../../../node_modules/bootstrap/scss/functions"; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index 1e00aa4ff7e..62024b8c555 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -336,3 +336,12 @@ input[type=color].form-control { .input-group-btn:last-child { @extend .input-group-append; } + +/* + Bootstrap 4.1.2 introduced a new default vertical alignment which breaks our icons, + so we need to reset the vertical alignment to the default value. See: + - https://gitlab.com/gitlab-org/gitlab-ce/issues/51362 + */ +svg { + vertical-align: baseline; +} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 4041f2b4479..834e7ffce81 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -65,3 +65,4 @@ @import 'framework/feature_highlight'; @import 'framework/terms'; @import 'framework/read_more'; +@import 'framework/flex_grid'; diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index fcf282a7d7c..054c75912ea 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -21,6 +21,7 @@ &.s46 { @include avatar-size(46px, 15px); } &.s48 { @include avatar-size(48px, 10px); } &.s60 { @include avatar-size(60px, 12px); } + &.s64 { @include avatar-size(64px, 14px); } &.s70 { @include avatar-size(70px, 14px); } &.s90 { @include avatar-size(90px, 15px); } &.s100 { @include avatar-size(100px, 15px); } @@ -80,6 +81,7 @@ &.s40 { font-size: 16px; line-height: 38px; } &.s48 { font-size: 20px; line-height: 46px; } &.s60 { font-size: 32px; line-height: 58px; } + &.s64 { font-size: 32px; line-height: 64px; } &.s70 { font-size: 34px; line-height: 70px; } &.s90 { font-size: 36px; line-height: 88px; } &.s100 { font-size: 36px; line-height: 98px; } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 219fd99b097..e36f99ac577 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -142,8 +142,14 @@ &.btn-sm { padding: 4px 10px; - font-size: 13px; - line-height: 18px; + font-size: $gl-btn-small-font-size; + line-height: $gl-btn-small-line-height; + } + + &.btn-xs { + padding: 2px $gl-btn-padding; + font-size: $gl-btn-small-font-size; + line-height: $gl-btn-small-line-height; } &.btn-success, diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss index bdd7f09d926..0d8e4afa76f 100644 --- a/app/assets/stylesheets/framework/callout.scss +++ b/app/assets/stylesheets/framework/callout.scss @@ -33,7 +33,11 @@ .bs-callout-warning { background-color: $orange-100; border-color: $orange-200; - color: $orange-700; + color: $orange-900; + + a { + color: $orange-900; + } } .bs-callout-info { diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 626c8f92d1d..f2f3a45ca09 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -386,3 +386,4 @@ img.emoji { .flex-no-shrink { flex-shrink: 0; } .mw-460 { max-width: 460px; } .ws-initial { white-space: initial; } +.min-height-0 { min-height: 0; } diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 6f103e4e89a..8b6a7017c47 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -261,7 +261,7 @@ height: 1px; margin: 4px -1px; padding: 0; - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; } > .active { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ce5d36a340f..f3c44f32d6f 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -294,10 +294,10 @@ height: 1px; margin: #{$grid-size / 2} 0; padding: 0; - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; &:hover { - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; } } @@ -306,7 +306,7 @@ height: 1px; margin-top: 8px; margin-bottom: 8px; - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; } .dropdown-menu-empty-item a { @@ -542,7 +542,7 @@ text-align: center; text-overflow: ellipsis; white-space: nowrap; - border-bottom: 1px solid $dropdown-divider-color; + border-bottom: 1px solid $dropdown-divider-bg; overflow: hidden; } @@ -621,7 +621,7 @@ padding: 0 7px; color: $gl-gray-700; line-height: 30px; - border: 1px solid $dropdown-divider-color; + border: 1px solid $dropdown-divider-bg; border-radius: 2px; outline: 0; @@ -656,7 +656,7 @@ padding-top: 10px; margin-top: 10px; font-size: 13px; - border-top: 1px solid $dropdown-divider-color; + border-top: 1px solid $dropdown-divider-bg; } .dropdown-footer-content { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index d5693a5d1a1..f48b3ddc912 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -363,6 +363,12 @@ background-color: $white-light; border-top: 0; } + + .filter-dropdown-container { + .dropdown { + margin-left: 0; + } + } } @include media-breakpoint-down(sm) { @@ -372,16 +378,6 @@ .dropdown-menu { width: 100%; } - - .dropdown { - margin-left: 0; - } - - .fa-chevron-down { - position: absolute; - right: 10px; - top: 10px; - } } } diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 7a4c3914fb0..afa85f0e4ae 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -32,16 +32,16 @@ margin: 0; } + .flash-text, + .flash-action { + display: inline-block; + } + .flash-alert { @extend .alert; background-color: $red-500; margin: 0; - .flash-text, - .flash-action { - display: inline-block; - } - .flash-action { margin-left: 5px; text-decoration: none; diff --git a/app/assets/stylesheets/framework/flex_grid.scss b/app/assets/stylesheets/framework/flex_grid.scss new file mode 100644 index 00000000000..10537fd5549 --- /dev/null +++ b/app/assets/stylesheets/framework/flex_grid.scss @@ -0,0 +1,52 @@ +.flex-grid { + .grid-row { + border-bottom: 1px solid $border-color; + padding: 0; + + &:last-child { + border-bottom: 0; + } + + @include media-breakpoint-down(md) { + border-bottom: 0; + border-right: 1px solid $border-color; + + &:last-child { + border-right: 0; + } + } + + @include media-breakpoint-down(xs) { + border-right: 0; + border-bottom: 1px solid $border-color; + + &:last-child { + border-bottom: 0; + } + } + } + + .grid-cell { + padding: 10px $gl-padding; + border-right: 1px solid $border-color; + + &:last-child { + border-right: 0; + } + + @include media-breakpoint-up(md) { + flex: 1; + } + + @include media-breakpoint-down(md) { + border-right: 0; + flex: none; + } + } +} + +.card { + .card-body.flex-grid { + padding: 0; + } +} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 39410ac56af..c0cda29e239 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -383,6 +383,16 @@ top: 1px; } } + + .dropdown-menu li a .identicon { + width: 17px; + height: 17px; + font-size: $gl-font-size-xs; + vertical-align: middle; + text-indent: 0; + line-height: $gl-font-size-xs + 2px; + display: inline-block; + } } .breadcrumbs-list { diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 452e946f95f..73533571a2f 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -42,11 +42,12 @@ padding: 10px; text-align: right; float: left; + line-height: 1; a { font-family: $monospace-font; display: block; - font-size: $code_font_size !important; + font-size: $code-font-size !important; min-height: 19px; white-space: nowrap; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index abd26e38d18..8db7d63266e 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -80,3 +80,15 @@ .user-avatar-link { text-decoration: none; } + +.circle-icon-container { + $border-size: 1px; + + display: flex; + align-items: center; + justify-content: center; + border: $border-size solid $theme-gray-400; + border-radius: 50%; + padding: $gl-padding-8 - $border-size; + color: $theme-gray-700; +} diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 6d20c46b99d..3bb046d0e51 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -39,15 +39,6 @@ .git-clone-holder { display: none; } - - // Display Star and Fork buttons without counters on mobile. - .project-repo-buttons { - display: block; - - .count-buttons .count-badge { - margin-top: $gl-padding-8; - } - } } .group-buttons { diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index de9e7c37695..19640ab5986 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -158,6 +158,10 @@ width: 100%; } + .dropdown-menu-toggle { + margin-bottom: 0; + } + form { display: block; height: auto; diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 4a311da1675..3d5208c3db5 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -31,16 +31,6 @@ .timeline-entry-inner { position: relative; - - @include notes-media('max', map-get($grid-breakpoints, sm)) { - .timeline-icon { - display: none; - } - - .timeline-content { - margin-left: 0; - } - } } &:target, diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index b43bb3feef5..134b3a4521b 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -197,6 +197,7 @@ $well-light-text-color: #5b6169; $gl-font-size: 14px; $gl-font-size-xs: 11px; $gl-font-size-small: 12px; +$gl-font-size-large: 16px; $gl-font-weight-normal: 400; $gl-font-weight-bold: 600; $gl-text-color: #2e2e2e; @@ -243,6 +244,7 @@ $gl-padding-top: 10px; $gl-sidebar-padding: 22px; $gl-bar-padding: 3px; $input-horizontal-padding: 12px; +$browserScrollbarSize: 10px; /* * Misc @@ -269,7 +271,8 @@ $performance-bar-height: 35px; $flash-height: 52px; $context-header-height: 60px; $breadcrumb-min-height: 48px; -$project-title-row-height: 24px; +$project-title-row-height: 64px; +$project-avatar-mobile-size: 24px; $gl-line-height: 16px; $gl-line-height-24: 24px; @@ -331,7 +334,6 @@ $dropdown-max-height: 312px; $dropdown-vertical-offset: 4px; $dropdown-empty-row-bg: rgba(#000, 0.04); $dropdown-shadow-color: rgba(#000, 0.1); -$dropdown-divider-color: rgba(#000, 0.1); $dropdown-title-btn-color: #bfbfbf; $dropdown-input-fa-color: #c7c7c7; $dropdown-input-focus-shadow: rgba($blue-300, 0.4); @@ -365,6 +367,8 @@ $gl-btn-padding: 10px; $gl-btn-line-height: 16px; $gl-btn-vert-padding: 8px; $gl-btn-horz-padding: 12px; +$gl-btn-small-font-size: 13px; +$gl-btn-small-line-height: 13px; /* * Badges diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index 759b4f333ca..fab1b361f14 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -19,3 +19,5 @@ $info: $blue-500; $warning: $orange-500; $danger: $red-500; $zindex-modal-backdrop: 1040; +$nav-divider-margin-y: ($grid-size / 2); +$dropdown-divider-bg: $theme-gray-200; diff --git a/app/assets/stylesheets/page_bundles/_ide_mixins.scss b/app/assets/stylesheets/page_bundles/_ide_mixins.scss new file mode 100644 index 00000000000..896a3466cb4 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/_ide_mixins.scss @@ -0,0 +1,18 @@ +@mixin ide-trace-view { + display: flex; + flex-direction: column; + height: 100%; + margin-top: -$grid-size; + margin-bottom: -$grid-size; + + &.build-page .top-bar { + top: 0; + height: auto; + font-size: 12px; + border-top-right-radius: $border-radius-default; + } + + .top-bar { + margin-left: -$gl-padding; + } +} diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 07d82e984ba..98d0a2d43ea 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -1,5 +1,6 @@ @import 'framework/variables'; @import 'framework/mixins'; +@import './ide_mixins'; $search-list-icon-width: 18px; $ide-activity-bar-width: 60px; @@ -1111,11 +1112,7 @@ $ide-commit-header-height: 48px; } .ide-pipeline { - display: flex; - flex-direction: column; - height: 100%; - margin-top: -$grid-size; - margin-bottom: -$grid-size; + @include ide-trace-view(); .empty-state { margin-top: auto; @@ -1133,17 +1130,9 @@ $ide-commit-header-height: 48px; } } - .build-trace, - .top-bar { + .build-trace { margin-left: -$gl-padding; } - - &.build-page .top-bar { - top: 0; - height: auto; - font-size: 12px; - border-top-right-radius: $border-radius-default; - } } .ide-pipeline-list { diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index c6074eb9df4..37984a8666f 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -41,7 +41,7 @@ .issue-board-dropdown-content { margin: 0 8px 10px; padding-bottom: 10px; - border-bottom: 1px solid $dropdown-divider-color; + border-bottom: 1px solid $dropdown-divider-bg; > p { margin: 0; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 8ea34f5d19d..bb6b6f84849 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -259,6 +259,16 @@ ul.related-merge-requests > li { display: block; } +.issue-sort-dropdown { + .btn-group { + width: 100%; + } + + .reverse-sort-btn { + color: $gl-text-color-secondary; + } +} + @include media-breakpoint-up(sm) { .emoji-block .row { display: flex; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index b075009b57c..221b4e934ff 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -50,9 +50,19 @@ .mr-widget-heading { position: relative; border: 1px solid $border-color; - border-radius: 4px; + border-radius: $border-radius-default; +} - &:not(.deploy-heading)::before { +.mr-widget-extension { + border-top: 1px solid $border-color; + background-color: $gray-light; +} + +.mr-widget-workflow { + margin-top: $gl-padding; + position: relative; + + &::before { content: ''; border-left: 1px solid $theme-gray-200; position: absolute; @@ -68,8 +78,8 @@ border-top: 0; } -.mr-widget-heading, .mr-widget-section, +.mr-widget-content, .mr-widget-footer { padding: $gl-padding; } @@ -560,19 +570,6 @@ color: $gl-text-color; } - .git-merge-icon-container { - border: 1px solid $theme-gray-400; - border-radius: 50%; - height: 32px; - width: 32px; - color: $theme-gray-700; - line-height: 28px; - - .ic-git-merge { - vertical-align: middle; - width: 31px; - } - } .git-merge-container { justify-content: space-between; @@ -854,11 +851,6 @@ } .deploy-heading { - margin-top: -19px; - border-top-left-radius: 0; - border-top-right-radius: 0; - background-color: $gray-light; - @include media-breakpoint-up(md) { padding: $gl-padding-8 $gl-padding; } @@ -868,6 +860,10 @@ font-size: 12px; margin-left: 48px; } + + &:not(:last-child) { + border-bottom: 1px solid $border-color; + } } .deploy-body { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 4fda2964fd5..39d01c49fd7 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -371,10 +371,10 @@ $note-form-margin-left: 72px; &::after { content: ''; - width: 100%; height: 70px; position: absolute; - left: 0; + left: $gl-padding-24; + right: 0; bottom: 0; background: linear-gradient(rgba($white-light, 0.1) -100px, $white-light 100%); } @@ -589,12 +589,6 @@ $note-form-margin-left: 72px; padding-bottom: 0; } -.note-header-author-name { - @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { - display: none; - } -} - .note-headline-light { display: inline; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 1d691d1d8b8..132f3fea92b 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -144,11 +144,13 @@ .provider-btn-group { display: inline-block; margin-right: 10px; + margin-bottom: 10px; border: 1px solid $border-color; border-radius: 3px; &:last-child { margin-right: 0; + margin-bottom: 0; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 80ec390d18e..278800aba95 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -144,7 +144,6 @@ .group-home-panel { padding-top: 24px; padding-bottom: 24px; - border-bottom: 1px solid $border-color; .group-avatar { float: none; @@ -155,7 +154,6 @@ } } - .project-title, .group-title { margin-top: 10px; margin-bottom: 10px; @@ -195,25 +193,69 @@ } .project-home-panel { - padding-top: $gl-padding-8; - padding-bottom: $gl-padding-24; - - .project-title-row { - margin-right: $gl-padding-8; - } + padding-top: $gl-padding; + padding-bottom: $gl-padding; .project-avatar { width: $project-title-row-height; height: $project-title-row-height; flex-shrink: 0; flex-basis: $project-title-row-height; - margin: 0 $gl-padding-8 0 0; + margin: 0 $gl-padding 0 0; } .project-title { + margin-top: 8px; + margin-bottom: 5px; font-size: 20px; - line-height: $project-title-row-height; + line-height: $gl-line-height-24; font-weight: bold; + + .icon { + font-size: $gl-font-size-large; + } + + .project-visibility { + color: $gl-text-color-secondary; + } + + .project-tag-list { + font-size: $gl-font-size; + font-weight: $gl-font-weight-normal; + + .icon { + position: relative; + top: 3px; + margin-right: $gl-padding-4; + } + } + } + + .project-title-row { + @include media-breakpoint-down(sm) { + .project-avatar { + width: $project-avatar-mobile-size; + height: $project-avatar-mobile-size; + flex-basis: $project-avatar-mobile-size; + + .avatar { + font-size: 20px; + line-height: 46px; + } + } + + .project-title { + margin-top: 4px; + margin-bottom: 2px; + font-size: $gl-font-size; + line-height: $gl-font-size-large; + } + + .project-tag-list, + .project-metadata { + font-size: $gl-font-size-small; + } + } } .project-metadata { @@ -222,16 +264,6 @@ line-height: $gl-btn-line-height; color: $gl-text-color-secondary; - .icon { - margin-right: $gl-padding-4; - font-size: 16px; - } - - .project-visibility, - .project-license, - .project-tag-list { - margin-right: $gl-padding-8; - } .project-license { .btn { @@ -240,12 +272,22 @@ } } - .project-tag-list, - .project-license { - .icon { - position: relative; - top: 2px; - } + .access-request-link, + .project-tag-list { + padding-left: $gl-padding-8; + border-left: 1px solid $gl-text-color-secondary; + } + } + + .project-description { + @include media-breakpoint-up(md) { + font-size: $gl-font-size-large; + } + } + + .notifications-btn { + .fa-bell { + margin-right: 0; } } } @@ -298,14 +340,6 @@ vertical-align: top; margin-top: $gl-padding; - .count-badge { - height: $input-height; - - .icon { - top: -1px; - } - } - .count-badge-count, .count-badge-button { border: 1px solid $border-color; @@ -319,29 +353,25 @@ .count-badge-count { padding: 0 12px; - border-right: 0; - border-radius: $border-radius-base 0 0 $border-radius-base; background: $gray-light; + border-radius: 0 $border-radius-base $border-radius-base 0; } .count-badge-button { - border-radius: 0 $border-radius-base $border-radius-base 0; + border-right: 0; + border-radius: $border-radius-base 0 0 $border-radius-base; } } .project-clone-holder { display: inline-block; - margin: $gl-padding $gl-padding-8 0 0; + margin: $gl-padding 0 0; input { height: $input-height; } } - .clone-dropdown-btn { - background-color: $white-light; - } - .clone-options-dropdown { min-width: 240px; @@ -355,6 +385,31 @@ } } +.project-repo-buttons { + .icon { + top: 0; + } + + .count-badge, + .btn-xs { + height: 24px; + } + + .dropdown-toggle, + .clone-dropdown-btn { + .fa { + color: unset; + } + } + + .btn { + .notifications-icon { + top: 1px; + margin-right: 0; + } + } +} + .split-one { display: inline-table; margin-right: 12px; @@ -715,15 +770,16 @@ border-bottom: 1px solid $border-color; } -.project-stats { +.project-stats, +.project-buttons { font-size: 0; text-align: center; - border-bottom: 1px solid $border-color; .scrolling-tabs-container { .scrolling-tabs { margin-top: $gl-padding-8; - margin-bottom: $gl-padding-8; + margin-bottom: $gl-padding-8 - $browserScrollbarSize; + padding-bottom: $browserScrollbarSize; flex-wrap: wrap; border-bottom: 0; } @@ -731,7 +787,7 @@ .fade-left, .fade-right { top: 0; - height: 100%; + height: calc(100% - #{$browserScrollbarSize}); .fa { top: 50%; @@ -785,23 +841,43 @@ font-size: $gl-font-size; line-height: $gl-btn-line-height; color: $gl-text-color-secondary; - white-space: nowrap; + white-space: pre-wrap; } .stat-link { border-bottom: 0; + color: $black; &:hover, &:focus { - color: $gl-text-color; text-decoration: underline; border-bottom: 0; } + + .project-stat-value { + color: $gl-text-color; + } + + .icon { + color: $gl-text-color-secondary; + } + + .add-license-link { + &, + .icon { + color: $blue-600; + } + } } .btn { - padding: $gl-btn-vert-padding $gl-btn-horz-padding; + margin-top: $gl-padding; + padding: $gl-btn-vert-padding $gl-btn-padding; line-height: $gl-btn-line-height; + + .icon { + top: 0; + } } .btn-missing { @@ -810,6 +886,13 @@ } } +.project-buttons { + .stat-text { + @extend .btn; + @extend .btn-default; + } +} + .repository-languages-bar { height: 8px; margin-bottom: $gl-padding-8; @@ -933,8 +1016,6 @@ pre.light-well { } .git-clone-holder { - width: 320px; - .btn-clipboard { border: 1px solid $border-color; } @@ -957,6 +1038,15 @@ pre.light-well { } } +.git-clone-holder, +.mobile-git-clone { + .btn { + .icon { + fill: $white; + } + } +} + .cannot-be-merged, .cannot-be-merged:hover { color: $red-500; diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index dc5ca78ff58..a46b8679a42 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -104,11 +104,23 @@ border-bottom: 1px solid $white-normal; border-top: 1px solid $white-normal; + &:last-of-type { + border-bottom-color: $white-light; + } + td, th { line-height: 21px; } + th { + border-top-color: $gray-light; + } + + td { + border-color: $border-color; + } + &:hover:not(.tree-truncated-warning) { td { background-color: $blue-50; diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb index 08d7e3b4fa2..65fe22bd8f4 100644 --- a/app/controllers/admin/impersonations_controller.rb +++ b/app/controllers/admin/impersonations_controller.rb @@ -5,23 +5,12 @@ class Admin::ImpersonationsController < Admin::ApplicationController before_action :authenticate_impersonator! def destroy - original_user = current_user - - warden.set_user(impersonator, scope: :user) - - Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{original_user.username}") - - session[:impersonator_id] = nil - + original_user = stop_impersonation redirect_to admin_user_path(original_user), status: :found end private - def impersonator - @impersonator ||= User.find(session[:impersonator_id]) if session[:impersonator_id] - end - def authenticate_impersonator! render_404 unless impersonator && impersonator.admin? && !impersonator.blocked? end diff --git a/app/controllers/admin/requests_profiles_controller.rb b/app/controllers/admin/requests_profiles_controller.rb index 64d74ae4231..57f7d3e3951 100644 --- a/app/controllers/admin/requests_profiles_controller.rb +++ b/app/controllers/admin/requests_profiles_controller.rb @@ -11,7 +11,7 @@ class Admin::RequestsProfilesController < Admin::ApplicationController profile = Gitlab::RequestProfiler::Profile.find(clean_name) if profile - render text: profile.content + render html: profile.content else redirect_to admin_requests_profiles_path, alert: 'Profile not found' end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index b783c0e2a6f..e93be1c1ba2 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -2,6 +2,7 @@ class Admin::UsersController < Admin::ApplicationController before_action :user, except: [:index, :new, :create] + before_action :check_impersonation_availability, only: :impersonate def index @users = User.order_name_asc.filter(params[:filter]) @@ -227,4 +228,8 @@ class Admin::UsersController < Admin::ApplicationController result[:status] == :success end + + def check_impersonation_availability + access_denied! unless Gitlab.config.gitlab.impersonation_enabled + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9b40ffb26a2..7c8c1392c1c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,15 +8,14 @@ class ApplicationController < ActionController::Base include GitlabRoutingHelper include PageLayoutHelper include SafeParamsHelper - include SentryHelper include WorkhorseHelper include EnforcesTwoFactorAuthentication include WithPerformanceBar + include SessionlessAuthentication # this can be removed after switching to rails 5 # https://gitlab.com/gitlab-org/gitlab-ce/issues/51908 include InvalidUTF8ErrorHandler unless Gitlab.rails5? - before_action :authenticate_sessionless_user! before_action :authenticate_user! before_action :enforce_terms!, if: :should_enforce_terms? before_action :validate_user_service_ticket! @@ -28,6 +27,7 @@ class ApplicationController < ActionController::Base before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? before_action :set_usage_stats_consent_flag + before_action :check_impersonation_availability around_action :set_locale @@ -128,6 +128,7 @@ class ApplicationController < ActionController::Base payload[:ua] = request.env["HTTP_USER_AGENT"] payload[:remote_ip] = request.remote_ip + payload[Gitlab::CorrelationId::LOG_KEY] = Gitlab::CorrelationId.current_id logged_user = auth_user @@ -153,15 +154,8 @@ class ApplicationController < ActionController::Base end end - # This filter handles personal access tokens, and atom requests with rss tokens - def authenticate_sessionless_user! - user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user - - sessionless_sign_in(user) if user - end - def log_exception(exception) - Raven.capture_exception(exception) if sentry_enabled? + Gitlab::Sentry.track_acceptable_exception(exception) backtrace_cleaner = Gitlab.rails5? ? request.env["action_dispatch.backtrace_cleaner"] : env application_trace = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).application_trace @@ -426,25 +420,11 @@ class ApplicationController < ActionController::Base Gitlab::I18n.with_user_locale(current_user, &block) end - def sessionless_sign_in(user) - if user && can?(user, :log_in) - # Notice we are passing store false, so the user is not - # actually stored in the session and a token is needed - # for every request. If you want the token to work as a - # sign in token, you can simply remove store: false. - sign_in(user, store: false, message: :sessionless_sign_in) - end - end - def set_page_title_header # Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8 response.headers['Page-Title'] = URI.escape(page_title('GitLab')) end - def sessionless_user? - current_user && !session.keys.include?('warden.user.user.key') - end - def peek_request? request.path.start_with?('/-/peek') end @@ -483,4 +463,32 @@ class ApplicationController < ActionController::Base .new(settings, current_user, application_setting_params) .execute end + + def check_impersonation_availability + return unless session[:impersonator_id] + + unless Gitlab.config.gitlab.impersonation_enabled + stop_impersonation + access_denied! _('Impersonation has been disabled') + end + end + + def stop_impersonation + impersonated_user = current_user + + Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{impersonated_user.username}") + + warden.set_user(impersonator, scope: :user) + session[:impersonator_id] = nil + + impersonated_user + end + + def impersonator + @impersonator ||= User.find(session[:impersonator_id]) if session[:impersonator_id] + end + + def sentry_context + Gitlab::Sentry.context(current_user) + end end diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 7f874687212..0dd7500623d 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -100,18 +100,12 @@ module Boards .merge(board_id: params[:board_id], list_id: params[:list_id], request: request) end + def serializer + IssueSerializer.new(current_user: current_user) + end + def serialize_as_json(resource) - resource.as_json( - only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position, :weight], - labels: true, - issue_endpoints: true, - include_full_project_path: board.group_board?, - include: { - project: { only: [:id, :path] }, - assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, - milestone: { only: [:id, :title] } - } - ) + serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?) end def whitelist_query_limiting diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb index b4f46cddbe9..8d518c14b90 100644 --- a/app/controllers/chaos_controller.rb +++ b/app/controllers/chaos_controller.rb @@ -15,7 +15,7 @@ class ChaosController < ActionController::Base duration_taken = (Time.now - start).seconds Kernel.sleep duration_s - duration_taken if duration_s > duration_taken - render text: "OK", content_type: 'text/plain' + render plain: "OK" end def cpuspin @@ -24,14 +24,14 @@ class ChaosController < ActionController::Base rand while Time.now < end_time - render text: "OK", content_type: 'text/plain' + render plain: "OK" end def sleep duration_s = (params[:duration_s]&.to_i || 30).seconds Kernel.sleep duration_s - render text: "OK", content_type: 'text/plain' + render plain: "OK" end def kill @@ -44,13 +44,13 @@ class ChaosController < ActionController::Base secret = ENV['GITLAB_CHAOS_SECRET'] # GITLAB_CHAOS_SECRET is required unless you're running in Development mode if !secret && !Rails.env.development? - render text: "chaos misconfigured: please configure GITLAB_CHAOS_SECRET when using GITLAB_ENABLE_CHAOS_ENDPOINTS outside of a development environment", content_type: 'text/plain', status: 500 + render plain: "chaos misconfigured: please configure GITLAB_CHAOS_SECRET when using GITLAB_ENABLE_CHAOS_ENDPOINTS outside of a development environment", status: :internal_server_error end return unless secret unless request.headers["HTTP_X_CHAOS_SECRET"] == secret - render text: "To experience chaos, please set X-Chaos-Secret header", content_type: 'text/plain', status: 401 + render plain: "To experience chaos, please set X-Chaos-Secret header", status: :unauthorized end end end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 34a8c50fcbd..a597996a362 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -91,7 +91,7 @@ module IssuableCollections options = { scope: params[:scope], state: params[:state], - sort: set_sort_order_from_cookie || default_sort_order + sort: set_sort_order } # Used by view to highlight active option @@ -102,7 +102,7 @@ module IssuableCollections elsif @group options[:group_id] = @group.id options[:include_subgroups] = true - options[:use_cte_for_search] = true + options[:attempt_group_search_optimizations] = true end params.permit(finder_type.valid_params).merge(options) @@ -113,6 +113,32 @@ module IssuableCollections 'opened' end + def set_sort_order + set_sort_order_from_user_preference || set_sort_order_from_cookie || default_sort_order + end + + def set_sort_order_from_user_preference + return unless current_user + return unless issuable_sorting_field + + user_preference = current_user.user_preference + + sort_param = params[:sort] + sort_param ||= user_preference[issuable_sorting_field] + + if user_preference[issuable_sorting_field] != sort_param + user_preference.update_attribute(issuable_sorting_field, sort_param) + end + + sort_param + end + + # Implement default_sorting_field method on controllers + # to choose which column to store the sorting parameter. + def issuable_sorting_field + nil + end + def set_sort_order_from_cookie sort_param = params[:sort] if params[:sort].present? # fallback to legacy cookie value for backward compatibility @@ -141,12 +167,6 @@ module IssuableCollections case value when 'id_asc' then sort_value_oldest_created when 'id_desc' then sort_value_recently_created - when 'created_asc' then sort_value_created_date - when 'created_desc' then sort_value_created_date - when 'due_date_asc' then sort_value_due_date - when 'due_date_desc' then sort_value_due_date - when 'milestone_due_asc' then sort_value_milestone - when 'milestone_due_desc' then sort_value_milestone when 'downvotes_asc' then sort_value_popularity when 'downvotes_desc' then sort_value_popularity else value diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 777b147e2dd..0319948a12f 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -6,6 +6,7 @@ module NotesActions extend ActiveSupport::Concern included do + prepend_before_action :normalize_create_params, only: [:create] before_action :set_polling_interval_header, only: [:index] before_action :require_noteable!, only: [:index, :create] before_action :authorize_admin_note!, only: [:update, :destroy] @@ -247,6 +248,15 @@ module NotesActions DiscussionSerializer.new(project: project, noteable: noteable, current_user: current_user, note_entity: ProjectNoteEntity) end + # Avoids checking permissions in the wrong object - this ensures that the object we checked permissions for + # is the object we're actually creating a note in. + def normalize_create_params + params[:note].try do |note| + note[:noteable_id] = params[:target_id] + note[:noteable_type] = params[:target_type].classify + end + end + def note_project strong_memoize(:note_project) do next nil unless project diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb new file mode 100644 index 00000000000..590eefc6dab --- /dev/null +++ b/app/controllers/concerns/sessionless_authentication.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# == SessionlessAuthentication +# +# Controller concern to handle PAT and RSS token authentication methods +# +module SessionlessAuthentication + # This filter handles personal access tokens, and atom requests with rss tokens + def authenticate_sessionless_user!(request_format) + user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user(request_format) + + sessionless_sign_in(user) if user + end + + def sessionless_user? + current_user && !session.keys.include?('warden.user.user.key') + end + + def sessionless_sign_in(user) + if user && can?(user, :log_in) + # Notice we are passing store false, so the user is not + # actually stored in the session and a token is needed + # for every request. If you want the token to work as a + # sign in token, you can simply remove store: false. + sign_in(user, store: false, message: :sessionless_sign_in) + end + end +end diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index 8c22490700c..014232a7d05 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -10,6 +10,8 @@ module SnippetsActions def raw disposition = params[:inline] == 'false' ? 'attachment' : 'inline' + workhorse_set_content_type! + send_data( convert_line_endings(@snippet.content), type: 'text/plain; charset=utf-8', diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 7a1c7abfb8f..0eea0cdd50f 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -1,17 +1,11 @@ # frozen_string_literal: true module UploadsActions - extend ActiveSupport::Concern - include Gitlab::Utils::StrongMemoize include SendFileUpload UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze - included do - prepend_before_action :set_html_format, only: :show - end - def create link_to_file = UploadService.new(model, params[:file], uploader_class).execute @@ -44,6 +38,7 @@ module UploadsActions return render_404 unless uploader + workhorse_set_content_type! send_upload(uploader, attachment: uploader.filename, disposition: disposition) end @@ -61,13 +56,6 @@ module UploadsActions private - # Explicitly set the format. - # Otherwise rails 5 will set it from a file extension. - # See https://github.com/rails/rails/commit/84e8accd6fb83031e4c27e44925d7596655285f7#diff-2b8f2fbb113b55ca8e16001c393da8f1 - def set_html_format - request.format = :html - end - def uploader_class raise NotImplementedError end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index e9686ed8d06..57e612d89d3 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -4,6 +4,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController include ParamsBackwardCompatibility include RendersMemberAccess + prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } before_action :set_non_archived_param before_action :default_sorting skip_cross_project_access_check :index, :starred diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index b82caf30a91..3fa582cf25b 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -4,6 +4,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController include ActionView::Helpers::NumberHelper before_action :authorize_read_project!, only: :index + before_action :authorize_read_group!, only: :index before_action :find_todos, only: [:index, :destroy_all] def index @@ -60,6 +61,15 @@ class Dashboard::TodosController < Dashboard::ApplicationController end end + def authorize_read_group! + group_id = params[:group_id] + + if group_id.present? + group = Group.find(group_id) + render_404 unless can?(current_user, :read_group, group) + end + end + def find_todos @todos ||= TodosFinder.new(current_user, todo_params).execute end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 4ce9be44403..be2d9512c01 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -4,6 +4,9 @@ class DashboardController < Dashboard::ApplicationController include IssuesAction include MergeRequestsAction + prepend_before_action(only: [:issues]) { authenticate_sessionless_user!(:rss) } + prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) } + before_action :event_filter, only: :activity before_action :projects, only: [:issues, :merge_requests] before_action :set_show_full_reference, only: [:issues, :merge_requests] diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index a1ec144410b..6ea4758ec32 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -3,6 +3,7 @@ class GraphqlController < ApplicationController # Unauthenticated users have access to the API for public data skip_before_action :authenticate_user! + prepend_before_action(only: [:execute]) { authenticate_sessionless_user!(:api) } before_action :check_graphql_feature_flag! diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb index 50c44b7a58b..b846fb21266 100644 --- a/app/controllers/groups/clusters_controller.rb +++ b/app/controllers/groups/clusters_controller.rb @@ -3,8 +3,8 @@ class Groups::ClustersController < Clusters::ClustersController include ControllerWithCrossProjectAccessCheck - prepend_before_action :check_group_clusters_feature_flag! prepend_before_action :group + prepend_before_action :check_group_clusters_feature_flag! requires_cross_project_access layout 'group' @@ -20,6 +20,10 @@ class Groups::ClustersController < Clusters::ClustersController end def check_group_clusters_feature_flag! - render_404 unless Feature.enabled?(:group_clusters) + render_404 unless group_clusters_enabled? + end + + def group_clusters_enabled? + group.group_clusters_enabled? end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 062c8c4e9e1..c5d8ac2ed77 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -9,6 +9,9 @@ class GroupsController < Groups::ApplicationController respond_to :html + prepend_before_action(only: [:show, :issues]) { authenticate_sessionless_user!(:rss) } + prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) } + before_action :authenticate_user!, only: [:new, :create] before_action :group, except: [:index, :new, :create] diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index 7353be478e1..c2089a0fca3 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -15,7 +15,7 @@ class MetricsController < ActionController::Base "# Metrics are disabled, see: #{help_page}\n" end - render text: response, content_type: 'text/plain; version=0.0.4' + render plain: response, content_type: 'text/plain; version=0.0.4' end private diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb index 84dce74ace8..384f308269a 100644 --- a/app/controllers/notification_settings_controller.rb +++ b/app/controllers/notification_settings_controller.rb @@ -16,7 +16,11 @@ class NotificationSettingsController < ApplicationController @notification_setting = current_user.notification_settings.find(params[:id]) @saved = @notification_setting.update(notification_setting_params_for(@notification_setting.source)) - render_response + if params[:hide_label].present? + render_response("projects/buttons/_notifications") + else + render_response + end end private @@ -37,9 +41,9 @@ class NotificationSettingsController < ApplicationController can?(current_user, ability_name, resource) end - def render_response + def render_response(response_template = "shared/notifications/_button") render json: { - html: view_to_html_string("shared/notifications/_button", notification_setting: @notification_setting), + html: view_to_html_string(response_template, notification_setting: @notification_setting), saved: @saved } end diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index b50f140dc80..ab4ca56bb49 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -9,7 +9,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController before_action :verify_user_oauth_applications_enabled, except: :index before_action :authenticate_user! before_action :add_gon_variables - before_action :load_scopes, only: [:index, :create, :edit] + before_action :load_scopes, only: [:index, :create, :edit, :update] helper_method :can? diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb index cb3180f4196..b0d65f284af 100644 --- a/app/controllers/profiles/accounts_controller.rb +++ b/app/controllers/profiles/accounts_controller.rb @@ -4,7 +4,7 @@ class Profiles::AccountsController < Profiles::ApplicationController include AuthHelper def show - @user = current_user + render(locals: show_view_variables) end # rubocop: disable CodeReuse/ActiveRecord @@ -23,4 +23,10 @@ class Profiles::AccountsController < Profiles::ApplicationController redirect_to profile_account_path end # rubocop: enable CodeReuse/ActiveRecord + + private + + def show_view_variables + {} + end end diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index 912421e3d08..dcee8eb7e6e 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -41,12 +41,12 @@ class Profiles::KeysController < Profiles::ApplicationController user = UserFinder.new(params[:username]).find_by_username if user.present? headers['Content-Disposition'] = 'attachment' - render text: user.all_ssh_keys.join("\n"), content_type: 'text/plain' + render plain: user.all_ssh_keys.join("\n") else return render_404 end rescue => e - render text: e.message + render html: e.message end else return render_404 diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index ae9c17802b9..1a91e07b97f 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -9,7 +9,6 @@ class Projects::ArtifactsController < Projects::ApplicationController before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] before_action :extract_ref_name_and_path - before_action :set_request_format, only: [:file] before_action :validate_artifacts!, except: [:download] before_action :entry, only: [:file] @@ -110,12 +109,4 @@ class Projects::ArtifactsController < Projects::ApplicationController render_404 unless @entry.exists? end - - def set_request_format - request.format = :html if set_request_format? - end - - def set_request_format? - request.format != :json - end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 873c96a5523..60fabd15333 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -9,7 +9,6 @@ class Projects::BlobController < Projects::ApplicationController include ActionView::Helpers::SanitizeHelper prepend_before_action :authenticate_user!, only: [:edit] - before_action :set_request_format, only: [:edit, :show, :update, :destroy] before_action :require_non_empty_project, except: [:new, :create] before_action :authorize_download_code! @@ -242,18 +241,6 @@ class Projects::BlobController < Projects::ApplicationController .last_for_path(@repository, @ref, @path).sha end - # In Rails 4.2 if params[:format] is empty, Rails set it to :html - # But since Rails 5.0 the framework now looks for an extension. - # E.g. for `blob/master/CHANGELOG.md` in Rails 4 the format would be `:html`, but in Rails 5 on it'd be `:md` - # This before_action explicitly sets the `:html` format for all requests unless `:format` is set by a client e.g. by JS for XHR requests. - def set_request_format - request.format = :html if set_request_format? - end - - def set_request_format? - params[:id].present? && params[:format].blank? && request.format != "json" - end - def show_html environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 95a014d24da..a6bfb913900 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -22,7 +22,7 @@ class Projects::BranchesController < Projects::ApplicationController # Fetch branches for the specified mode fetch_branches_by_mode - @refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name)) + @refs_pipelines = @project.ci_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/gitaly/issues/992 diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 84a2a461da7..e40a1a1d744 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -6,12 +6,12 @@ class Projects::CommitsController < Projects::ApplicationController include ExtractsPath include RendersCommits + prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } before_action :whitelist_query_limiting, except: :commits_root before_action :require_non_empty_project before_action :assign_ref_vars, except: :commits_root before_action :authorize_download_code! before_action :set_commits, except: :commits_root - before_action :set_request_format, only: :show def commits_root redirect_to project_commits_path(@project, @project.default_branch) @@ -70,19 +70,6 @@ class Projects::CommitsController < Projects::ApplicationController @commits = set_commits_for_rendering(@commits) end - # Rails 5 sets request.format from the extension. - # Explicitly set to :html. - def set_request_format - request.format = :html if set_request_format? - end - - # Rails 5 sets request.format from extension. - # In this case if the ref ends with `.atom`, it's expected to be the html response, - # not the atom one. So explicitly set request.format as :html to act like rails4. - def set_request_format? - request.format.to_s == "text/html" || @commits.ref.ends_with?("atom") - end - def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42330') end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index de10783df1a..e940f382a19 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -122,7 +122,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController set_workhorse_internal_api_content_type render json: Gitlab::Workhorse.terminal_websocket(terminal) else - render text: 'Not found', status: :not_found + render html: 'Not found', status: :not_found end end diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index e55065c5817..a10e159ea1e 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -13,10 +13,8 @@ class Projects::ImportsController < Projects::ApplicationController end def create - @project.import_url = params[:project][:import_url] - - if @project.save - @project.reload.import_schedule + if @project.update(safe_import_params) + @project.import_state.reload.schedule end redirect_to project_import_path(@project) @@ -24,7 +22,7 @@ class Projects::ImportsController < Projects::ApplicationController def show if @project.import_finished? - if continue_params + if continue_params&.key?(:to) redirect_to continue_params[:to], notice: continue_params[:notice] else redirect_to project_path(@project), notice: finished_notice @@ -67,4 +65,12 @@ class Projects::ImportsController < Projects::ApplicationController redirect_to project_path(@project) end end + + def import_params + params.require(:project).permit(:import_url) + end + + def safe_import_params + import_params + end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d6d7110355b..c6ab6b4642e 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -9,10 +9,6 @@ class Projects::IssuesController < Projects::ApplicationController include IssuesCalendar include SpammableActions - def self.authenticate_user_only_actions - %i[new] - end - def self.issue_except_actions %i[index calendar new create bulk_update] end @@ -21,7 +17,10 @@ class Projects::IssuesController < Projects::ApplicationController %i[index calendar] end - prepend_before_action :authenticate_user!, only: authenticate_user_only_actions + prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } + prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) } + prepend_before_action :authenticate_new_issue!, only: [:new] + prepend_before_action :store_uri, only: [:new, :show] before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update] before_action :check_issues_available! @@ -232,16 +231,18 @@ class Projects::IssuesController < Projects::ApplicationController ] + [{ label_ids: [], assignee_ids: [] }] end - def authenticate_user! + def authenticate_new_issue! return if current_user notice = "Please sign in to create the new issue." + redirect_to new_user_session_path, notice: notice + end + + def store_uri if request.get? && !request.xhr? store_location_for :user, request.fullpath end - - redirect_to new_user_session_path, notice: notice end def serializer diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 3ecf94c008e..c58b30eace7 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -140,15 +140,22 @@ class Projects::JobsController < Projects::ApplicationController def raw if trace_artifact_file + workhorse_set_content_type! send_upload(trace_artifact_file, send_params: raw_send_params, redirect_params: raw_redirect_params) else build.trace.read do |stream| if stream.file? + workhorse_set_content_type! send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' else - send_data stream.raw, type: 'text/plain; charset=utf-8', disposition: 'inline', filename: 'job.log' + # In this case we can't use workhorse_set_content_type! and let + # Workhorse handle the response because the data is streamed directly + # to the user but, because we have the trace content, we can calculate + # the proper content type and disposition here. + raw_data = stream.raw + send_data raw_data, type: 'text/plain; charset=utf-8', disposition: raw_trace_content_disposition(raw_data), filename: 'job.log' end end end @@ -201,4 +208,13 @@ class Projects::JobsController < Projects::ApplicationController def build_path(build) project_job_path(build.project, build) end + + def raw_trace_content_disposition(raw_data) + mime_type = MimeMagic.by_magic(raw_data) + + # if mime_type is nil can also represent 'text/plain' + return 'inline' if mime_type.nil? || mime_type.type == 'text/plain' + + 'attachment' + end end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index b3d77335c2a..ddffbb17ace 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -22,12 +22,9 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic def render_diffs @environment = @merge_request.environments_for(current_user).last - notes_grouped_by_path = renderable_notes.group_by { |note| note.position.file_path } - @diffs.diff_files.each do |diff_file| - notes = notes_grouped_by_path.fetch(diff_file.file_path, []) - notes.each { |note| diff_file.unfold_diff_lines(note.position) } - end + note_positions = renderable_notes.map(&:position).compact + @diffs.unfold_diff_files(note_positions) @diffs.write_cache diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index d521db79f85..da9316d5f22 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -122,17 +122,21 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo respond_to do |format| format.html do - if @merge_request.valid? - redirect_to([@merge_request.target_project.namespace.becomes(Namespace), @merge_request.target_project, @merge_request]) - else + if @merge_request.errors.present? define_edit_vars render :edit + else + redirect_to project_merge_request_path(@merge_request.target_project, @merge_request) end end format.json do - render json: serializer.represent(@merge_request, serializer: 'basic') + if merge_request.errors.present? + render json: @merge_request.errors, status: :bad_request + else + render json: serializer.represent(@merge_request, serializer: 'basic') + end end end rescue ActiveRecord::StaleObjectError diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 20998c97730..8e68014a30d 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -11,7 +11,10 @@ class Projects::MilestonesController < Projects::ApplicationController before_action :authorize_read_milestone! # Allow admin milestone - before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels, :promote] + before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels] + + # Allow to promote milestone + before_action :authorize_promote_milestone!, only: :promote respond_to :html @@ -78,7 +81,7 @@ class Projects::MilestonesController < Projects::ApplicationController def promote promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) - flash[:notice] = flash_notice_for(promoted_milestone, project.group) + flash[:notice] = flash_notice_for(promoted_milestone, project_group) respond_to do |format| format.html do @@ -109,6 +112,12 @@ class Projects::MilestonesController < Projects::ApplicationController protected + def project_group + strong_memoize(:project_group) do + project.group + end + end + def milestones strong_memoize(:milestones) do MilestonesFinder.new(search_params).execute @@ -125,13 +134,17 @@ class Projects::MilestonesController < Projects::ApplicationController return render_404 unless can?(current_user, :admin_milestone, @project) end + def authorize_promote_milestone! + return render_404 unless can?(current_user, :admin_milestone, project_group) + end + def milestone_params params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event) end def search_params - if request.format.json? && @project.group && can?(current_user, :read_group, @project.group) - groups = @project.group.self_and_ancestors_ids + if request.format.json? && project_group && can?(current_user, :read_group, project_group) + groups = project_group.self_and_ancestors_ids end params.permit(:state).merge(project_ids: @project.id, group_ids: groups) diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb index ad2466a8588..6543711ecfa 100644 --- a/app/controllers/projects/network_controller.rb +++ b/app/controllers/projects/network_controller.rb @@ -8,6 +8,7 @@ class Projects::NetworkController < Projects::ApplicationController before_action :require_non_empty_project before_action :assign_ref_vars before_action :authorize_download_code! + before_action :assign_options before_action :assign_commit def show @@ -29,10 +30,13 @@ class Projects::NetworkController < Projects::ApplicationController render end + def assign_options + @options = params.permit(:filter_ref, :extended_sha1) + end + def assign_commit - return if params[:extended_sha1].blank? + return if @options[:extended_sha1].blank? - @options[:extended_sha1] = params[:extended_sha1] @commit = @repo.commit(@options[:extended_sha1]) end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 53b29d4146e..67827b1d3bb 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -46,7 +46,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def new - @pipeline = project.pipelines.new(ref: @project.default_branch) + @pipeline = project.all_pipelines.new(ref: @project.default_branch) end def create @@ -142,9 +142,9 @@ class Projects::PipelinesController < Projects::ApplicationController @charts[:pipeline_times] = Gitlab::Ci::Charts::PipelineTime.new(project) @counts = {} - @counts[:total] = @project.pipelines.count(:all) - @counts[:success] = @project.pipelines.success.count(:all) - @counts[:failed] = @project.pipelines.failed.count(:all) + @counts[:total] = @project.all_pipelines.count(:all) + @counts[:success] = @project.all_pipelines.success.count(:all) + @counts[:failed] = @project.all_pipelines.failed.count(:all) end private @@ -164,7 +164,7 @@ class Projects::PipelinesController < Projects::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def pipeline @pipeline ||= project - .pipelines + .all_pipelines .includes(user: :status) .find_by!(id: params[:id]) .present(current_user: current_user) diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb new file mode 100644 index 00000000000..0af2b7ef343 --- /dev/null +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class FunctionsController < Projects::ApplicationController + include ProjectUnauthorized + + before_action :authorize_read_cluster! + + INDEX_PRIMING_INTERVAL = 10_000 + INDEX_POLLING_INTERVAL = 30_000 + + def index + finder = Projects::Serverless::FunctionsFinder.new(project.clusters) + + respond_to do |format| + format.json do + functions = finder.execute + + if functions.any? + Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL) + render json: Projects::Serverless::ServiceSerializer.new(current_user: @current_user).represent(functions) + else + Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL) + head :no_content + end + end + + format.html do + @installed = finder.installed? + render + end + end + end + end + end +end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 1d76c90d4eb..30724de7f6a 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -5,6 +5,7 @@ module Projects class RepositoryController < Projects::ApplicationController before_action :authorize_admin_project! before_action :remote_mirror, only: [:show] + before_action :check_cleanup_feature_flag!, only: :cleanup def show render_show @@ -20,8 +21,26 @@ module Projects render_show end + def cleanup + cleanup_params = params.require(:project).permit(:bfg_object_map) + result = Projects::UpdateService.new(project, current_user, cleanup_params).execute + + if result[:status] == :success + RepositoryCleanupWorker.perform_async(project.id, current_user.id) + flash[:notice] = _('Repository cleanup has started. You will receive an email once the cleanup operation is complete.') + else + flash[:alert] = _('Failed to upload object map file') + end + + redirect_to project_settings_repository_path(project) + end + private + def check_cleanup_feature_flag! + render_404 unless ::Feature.enabled?(:project_cleanup, project) + end + def render_show @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user) @deploy_tokens = @project.deploy_tokens.active diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index c8442ff3592..686d66b10a3 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -3,6 +3,8 @@ class Projects::TagsController < Projects::ApplicationController include SortingHelper + prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } + # Authorize before_action :require_non_empty_project before_action :authorize_download_code! @@ -18,7 +20,7 @@ class Projects::TagsController < Projects::ApplicationController @tags = Kaminari.paginate_array(@tags).page(params[:page]) tag_names = @tags.map(&:name) - @tags_pipelines = @project.pipelines.latest_successful_for_refs(tag_names) + @tags_pipelines = @project.ci_pipelines.latest_successful_for_refs(tag_names) @releases = project.releases.where(tag: tag_names) respond_to do |format| diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 7f4a9f5151b..8bf93bfd68d 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -7,6 +7,8 @@ class ProjectsController < Projects::ApplicationController include PreviewMarkdown include SendFileUpload + prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } + before_action :whitelist_query_limiting, only: [:create] before_action :authenticate_user!, except: [:index, :show, :activity, :refs] before_action :redirect_git_extension, only: [:show] diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 5b70c69d7f4..8b040dc080e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -14,6 +14,7 @@ class UsersController < ApplicationController calendar_activities: true skip_before_action :authenticate_user! + prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } before_action :user, except: [:exists] before_action :authorize_read_user_profile!, only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :snippets] diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index e04e3a2a7e0..b73a3fa6e01 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -27,12 +27,13 @@ # created_before: datetime # updated_after: datetime # updated_before: datetime -# use_cte_for_search: boolean +# attempt_group_search_optimizations: boolean # class IssuableFinder prepend FinderWithCrossProjectAccess include FinderMethods include CreatedAtFilter + include Gitlab::Utils::StrongMemoize requires_cross_project_access unless: -> { project? } @@ -75,8 +76,9 @@ class IssuableFinder items = init_collection items = filter_items(items) - # This has to be last as we may use a CTE as an optimization fence by - # passing the use_cte_for_search param + # This has to be last as we may use a CTE as an optimization fence + # by passing the attempt_group_search_optimizations param and + # enabling the use_cte_for_group_issues_search feature flag # https://www.postgresql.org/docs/current/static/queries-with.html items = by_search(items) @@ -85,6 +87,8 @@ class IssuableFinder def filter_items(items) items = by_project(items) + items = by_group(items) + items = by_subquery(items) items = by_scope(items) items = by_created_at(items) items = by_updated_at(items) @@ -282,12 +286,31 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord + def use_subquery_for_search? + strong_memoize(:use_subquery_for_search) do + attempt_group_search_optimizations? && + Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: false) + end + end + + def use_cte_for_search? + strong_memoize(:use_cte_for_search) do + attempt_group_search_optimizations? && + !use_subquery_for_search? && + Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true) + end + end + private def init_collection klass.all end + def attempt_group_search_optimizations? + search && Gitlab::Database.postgresql? && params[:attempt_group_search_optimizations] + end + def count_key(value) Array(value).last.to_sym end @@ -351,12 +374,13 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord - def use_cte_for_search? - return false unless search - return false unless Gitlab::Database.postgresql? - return false unless Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true) - - params[:use_cte_for_search] + # Wrap projects and groups in a subquery if the conditions are met. + def by_subquery(items) + if use_subquery_for_search? + klass.where(id: items.select(:id)) # rubocop: disable CodeReuse/ActiveRecord + else + items + end end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index 35d0e1acce5..f5aadc42ff0 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -8,7 +8,7 @@ class PipelinesFinder def initialize(project, current_user, params = {}) @project = project @current_user = current_user - @pipelines = project.pipelines + @pipelines = project.all_pipelines @params = params end diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb new file mode 100644 index 00000000000..2b5d67e79d7 --- /dev/null +++ b/app/finders/projects/serverless/functions_finder.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class FunctionsFinder + def initialize(clusters) + @clusters = clusters + end + + def execute + knative_services.flatten.compact + end + + def installed? + clusters_with_knative_installed.exists? + end + + private + + def knative_services + clusters_with_knative_installed.preload_knative.map do |cluster| + cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + end + end + + def clusters_with_knative_installed + @clusters.with_knative_installed + end + end + end +end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index ed13c5cfdd6..3f69af50f25 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -2,7 +2,12 @@ module AppearancesHelper def brand_title - current_appearance&.title.presence || 'GitLab Community Edition' + current_appearance&.title.presence || default_brand_title + end + + def default_brand_title + # This resides in a separate method so that EE can easily redefine it. + 'GitLab Community Edition' end def brand_image diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 44f85e9c0f8..654fb9d9987 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -57,6 +57,10 @@ module AuthHelper auth_providers.reject { |provider| form_based_provider?(provider) } end + def display_providers_on_profile? + button_based_providers.any? + end + def providers_for_base_controller auth_providers.reject { |provider| LDAP_PROVIDER === provider } end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 638744a1426..bd42f00944f 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -140,6 +140,8 @@ module BlobHelper Gitlab::Sanitizers::SVG.clean(data) end + # Remove once https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 is closed + # and :workhorse_set_content_type flag is removed # If we blindly set the 'real' content type when serving a Git blob we # are enabling XSS attacks. An attacker could upload e.g. a Javascript # file to a Git repository, trick the browser of a victim into @@ -161,6 +163,8 @@ module BlobHelper end def content_disposition(blob, inline) + # Remove the following line when https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 + # is closed and :workhorse_set_content_type flag is removed return 'attachment' if blob.extension == 'svg' inline ? 'inline' : 'attachment' diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 7f071d55a6b..494c754e7d5 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -85,13 +85,14 @@ module ButtonHelper dropdown_item_with_description('SSH', dropdown_description, href: append_url, data: { clone_type: 'ssh' }) end - def dropdown_item_with_description(title, description, href: nil, data: nil) + def dropdown_item_with_description(title, description, href: nil, data: nil, default: false) + active_class = "is-active" if default button_content = content_tag(:strong, title, class: 'dropdown-menu-inner-title') button_content << content_tag(:span, description, class: 'dropdown-menu-inner-content') if description content_tag (href ? :a : :span), (href ? button_content : title), - class: "#{title.downcase}-selector", + class: "#{title.downcase}-selector #{active_class}", href: (href if href), data: (data if data) end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index e9b9b9b7721..866fc555856 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -140,7 +140,7 @@ module GroupsHelper can?(current_user, "read_group_#{resource}".to_sym, @group) end - if can?(current_user, :read_cluster, @group) && Feature.enabled?(:group_clusters) + if can?(current_user, :read_cluster, @group) && @group.group_clusters_enabled? links << :kubernetes end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index b0f63de2fb8..4e11772b252 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -42,7 +42,7 @@ module IconsHelper end def sprite_icon(icon_name, size: nil, css_class: nil) - if Gitlab::Sentry.should_raise? + if Gitlab::Sentry.should_raise_for_dev? unless known_sprites.include?(icon_name) exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg") raise exception diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb new file mode 100644 index 00000000000..8e50bbc6c04 --- /dev/null +++ b/app/helpers/ide_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module IdeHelper + def ide_data + { + "empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), + "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), + "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'), + "pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'), + "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'), + "ci-help-page-path" => help_page_path('ci/quick_start/README'), + "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'), + "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s + } + end +end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 94a030d9d57..9666080092b 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -2,6 +2,7 @@ module MilestonesHelper include EntityDateHelper + include Gitlab::Utils::StrongMemoize def milestones_filter_path(opts = {}) if @project @@ -243,4 +244,16 @@ module MilestonesHelper dashboard_milestone_path(milestone.safe_title, title: milestone.title) end end + + def can_admin_project_milestones? + strong_memoize(:can_admin_project_milestones) do + can?(current_user, :admin_milestone, @project) + end + end + + def can_admin_group_milestones? + strong_memoize(:can_admin_group_milestones) do + can?(current_user, :admin_milestone, @project.group) + end + end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 0a7f930110a..7ce6b04df7e 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -257,6 +257,10 @@ module ProjectsHelper "xcode://clone?repo=#{CGI.escape(default_url_to_repo(project))}" end + def link_to_bfg + link_to 'BFG', 'https://rtyley.github.io/bfg-repo-cleaner/', target: '_blank', rel: 'noopener noreferrer' + end + def legacy_render_context(params) params[:legacy_render] ? { markdown_engine: :redcarpet } : {} end @@ -307,6 +311,7 @@ module ProjectsHelper settings: :admin_project, builds: :read_build, clusters: :read_cluster, + serverless: :read_cluster, labels: :read_label, issues: :read_issue, project_members: :read_project_member, @@ -545,6 +550,7 @@ module ProjectsHelper %w[ environments clusters + functions user gcp ] diff --git a/app/helpers/sentry_helper.rb b/app/helpers/sentry_helper.rb deleted file mode 100644 index d53eaef9952..00000000000 --- a/app/helpers/sentry_helper.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module SentryHelper - def sentry_enabled? - Gitlab::Sentry.enabled? - end - - def sentry_context - Gitlab::Sentry.context(current_user) - end -end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 8ed2a2ec9f4..f51b96ba8ce 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -120,10 +120,69 @@ module SortingHelper } end + def users_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_recently_signin => sort_title_recently_signin, + sort_value_oldest_signin => sort_title_oldest_signin, + sort_value_recently_created => sort_title_recently_created, + sort_value_oldest_created => sort_title_oldest_created, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated + } + end + def sortable_item(item, path, sorted_by) link_to item, path, class: sorted_by == item ? 'is-active' : '' end + def issuable_sort_option_overrides + { + sort_value_oldest_created => sort_value_created_date, + sort_value_oldest_updated => sort_value_recently_updated, + sort_value_milestone_later => sort_value_milestone + } + end + + def issuable_reverse_sort_order_hash + { + sort_value_created_date => sort_value_oldest_created, + sort_value_recently_created => sort_value_oldest_created, + sort_value_recently_updated => sort_value_oldest_updated, + sort_value_milestone => sort_value_milestone_later + }.merge(issuable_sort_option_overrides) + end + + def issuable_sort_option_title(sort_value) + sort_value = issuable_sort_option_overrides[sort_value] || sort_value + + sort_options_hash[sort_value] + end + + def issuable_sort_direction_button(sort_value) + link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort' + reverse_sort = issuable_reverse_sort_order_hash[sort_value] + + if reverse_sort + reverse_url = page_filter_path(sort: reverse_sort) + else + reverse_url = '#' + link_class += ' disabled' + end + + link_to(reverse_url, type: 'button', class: link_class, title: 'Sort direction') do + icon_suffix = + case sort_value + when sort_value_milestone, sort_value_due_date, /_asc\z/ + 'lowest' + else + 'highest' + end + + sprite_icon("sort-#{icon_suffix}", size: 16) + end + end + # Titles. def sort_title_access_level_asc s_('SortOptions|Access level, ascending') diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 42b533ad772..bde9ca0cbf2 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -70,6 +70,10 @@ module UsersHelper end end + def impersonation_enabled? + Gitlab.config.gitlab.impersonation_enabled + end + private def get_profile_tabs diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index e690350a0d1..712f0f808dd 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -140,7 +140,7 @@ module VisibilityLevelHelper end def project_visibility_icon_description(level) - "#{project_visibility_level_description(level)}" + "#{visibility_level_label(level)} - #{project_visibility_level_description(level)}" end def visibility_level_label(level) diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index 49c08dce96c..e9fc39e451b 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -6,8 +6,13 @@ module WorkhorseHelper # Send a Git blob through Workhorse def send_git_blob(repository, blob, inline: true) headers.store(*Gitlab::Workhorse.send_git_blob(repository, blob)) + headers['Content-Disposition'] = content_disposition(blob, inline) headers['Content-Type'] = safe_content_type(blob) + + # If enabled, this will override the values set above + workhorse_set_content_type! + render plain: "" end @@ -40,4 +45,8 @@ module WorkhorseHelper def set_workhorse_internal_api_content_type headers['Content-Type'] = Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE end + + def workhorse_set_content_type! + headers[Gitlab::Workhorse::DETECT_HEADER] = "true" if Feature.enabled?(:workhorse_set_content_type) + end end diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index d3284e90568..1b3c1f9a8a9 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -26,7 +26,7 @@ module Emails mail_answer_note_thread(@merge_request, @note, note_thread_options(recipient_id)) end - def note_snippet_email(recipient_id, note_id) + def note_project_snippet_email(recipient_id, note_id) setup_note_mail(note_id, recipient_id) @snippet = @note.noteable diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index d7e6c2ba7b2..2500622caa7 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -24,6 +24,21 @@ module Emails subject: subject("Project export error")) end + def repository_cleanup_success_email(project, user) + @project = project + @user = user + + mail(to: user.notification_email, subject: subject("Project cleanup has completed")) + end + + def repository_cleanup_failure_email(project, user, error) + @project = project + @user = user + @error = error + + mail(to: user.notification_email, subject: subject("Project cleanup failure")) + end + def repository_push_email(project_id, opts = {}) @message = Gitlab::Email::Message::RepositoryPush.new(self, project_id, opts) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 207ffae873a..4319db42019 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -7,7 +7,7 @@ class ApplicationSetting < ActiveRecord::Base include IgnorableColumn include ChronicDurationAttribute - add_authentication_token_field :runners_registration_token + add_authentication_token_field :runners_registration_token, encrypted: true, fallback: true add_authentication_token_field :health_check_access_token DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index d60861dc95f..d86a6eceb59 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -120,7 +120,7 @@ module Ci acts_as_taggable - add_authentication_token_field :token + add_authentication_token_field :token, encrypted: true, fallback: true before_save :update_artifacts_size, if: :artifacts_file_changed? before_save :ensure_token diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 7c84bd734bb..da08214963f 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -15,6 +15,8 @@ module Ci WRITE_LOCK_SLEEP = 0.01.seconds WRITE_LOCK_TTL = 1.minute + FailedToPersistDataError = Class.new(StandardError) + # Note: The ordering of this enum is related to the precedence of persist store. # The bottom item takes the higest precedence, and the top item takes the lowest precedence. enum data_store: { @@ -109,16 +111,19 @@ module Ci def unsafe_persist_to!(new_store) return if data_store == new_store.to_s - raise ArgumentError, 'Can not persist empty data' unless size > 0 - old_store_class = self.class.get_store_class(data_store) + current_data = get_data - get_data.tap do |the_data| - self.raw_data = nil - self.data_store = new_store - unsafe_set_data!(the_data) + unless current_data&.bytesize.to_i == CHUNK_SIZE + raise FailedToPersistDataError, 'Data is not fullfilled in a bucket' end + old_store_class = self.class.get_store_class(data_store) + + self.raw_data = nil + self.data_store = new_store + unsafe_set_data!(current_data) + old_store_class.delete_data(self) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 9512ba42f67..d06022a0fb7 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -12,13 +12,14 @@ module Ci include AtomicInternalId include EnumWithNil - belongs_to :project, inverse_of: :pipelines + belongs_to :project, inverse_of: :all_pipelines belongs_to :user belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule' + belongs_to :merge_request, class_name: 'MergeRequest' has_internal_id :iid, scope: :project, presence: false, init: ->(s) do - s&.project&.pipelines&.maximum(:iid) || s&.project&.pipelines&.count + s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count end has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline @@ -26,6 +27,8 @@ module Ci has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent has_many :variables, class_name: 'Ci::PipelineVariable' + has_many :deployments, through: :builds + has_many :environments, -> { distinct }, through: :deployments # Merge requests for which the current pipeline is running against # the merge request's latest commit. @@ -48,6 +51,9 @@ module Ci validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } + validates :merge_request, presence: { if: :merge_request? } + validates :merge_request, absence: { unless: :merge_request? } + validates :tag, inclusion: { in: [false], if: :merge_request? } validates :status, presence: { unless: :importing? } validate :valid_commit_sha, unless: :importing? @@ -168,6 +174,16 @@ module Ci end scope :internal, -> { where(source: internal_sources) } + scope :ci_sources, -> { where(config_source: ci_sources_values) } + + scope :sort_by_merge_request_pipelines, -> do + sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC' + query = ActiveRecord::Base.send(:sanitize_sql_array, [sql, sources[:merge_request]]) # rubocop:disable GitlabSecurity/PublicSend + + order(query) + end + + scope :for_user, -> (user) { where(user: user) } # Returns the pipelines in descending order (= newest first), optionally # limited to a number of references. @@ -256,6 +272,10 @@ module Ci sources.reject { |source| source == "external" }.values end + def self.ci_sources_values + config_sources.values_at(:repository_source, :auto_devops_source, :unknown_source) + end + def stages_count statuses.select(:stage).distinct.count end @@ -368,7 +388,7 @@ module Ci end def branch? - !tag? + !tag? && !merge_request? end def stuck? @@ -494,6 +514,8 @@ module Ci end def ci_yaml_file_path + return unless repository_source? || unknown_source? + if project.ci_config_path.blank? '.gitlab-ci.yml' else @@ -523,10 +545,6 @@ module Ci yaml_errors.present? end - def environments - builds.where.not(environment: nil).success.pluck(:environment).uniq - end - # Manually set the notes for a Ci::Pipeline # There is no ActiveRecord relation between Ci::Pipeline and notes # as they are related to a commit sha. This method helps importing @@ -587,13 +605,18 @@ module Ci end def predefined_variables - Gitlab::Ci::Variables::Collection.new - .append(key: 'CI_PIPELINE_IID', value: iid.to_s) - .append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) - .append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) - .append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) - .append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) - .append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_PIPELINE_IID', value: iid.to_s) + variables.append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) + variables.append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) + variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) + variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) + variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) + + if merge_request? && merge_request + variables.concat(merge_request.predefined_variables) + end + end end def queued_duration @@ -617,7 +640,12 @@ module Ci # All the merge requests for which the current pipeline runs/ran against def all_merge_requests - @all_merge_requests ||= project.merge_requests.where(source_branch: ref) + @all_merge_requests ||= + if merge_request? + project.merge_requests.where(id: merge_request.id) + else + project.merge_requests.where(source_branch: ref) + end end def detailed_status(current_user) @@ -666,6 +694,7 @@ module Ci def ci_yaml_from_repo return unless project return unless sha + return unless ci_yaml_file_path project.repository.gitlab_ci_yml_for(sha, ci_yaml_file_path) rescue GRPC::NotFound, GRPC::Internal @@ -693,6 +722,8 @@ module Ci def git_ref if branch? Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s + elsif merge_request? + Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s elsif tag? Gitlab::Git::TAG_REF_PREFIX + ref.to_s else diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 8d8d16e2ec1..c0f16066e0b 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -21,7 +21,8 @@ module Ci trigger: 3, schedule: 4, api: 5, - external: 6 + external: 6, + merge_request: 10 } end end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 31330d0682e..2693386443a 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -8,6 +8,9 @@ module Ci include RedisCacheable include ChronicDurationAttribute include FromUnion + include TokenAuthenticatable + + add_authentication_token_field :token, encrypted: true, migrating: true enum access_level: { not_protected: 0, @@ -39,7 +42,7 @@ module Ci has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build' - before_validation :set_default_values + before_save :ensure_token scope :active, -> { where(active: true) } scope :paused, -> { where(active: false) } @@ -111,7 +114,8 @@ module Ci cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at - chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout + chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout, + error_message: 'Maximum job timeout has a value which could not be accepted' validates :maximum_timeout, allow_nil: true, numericality: { greater_than_or_equal_to: 600, @@ -145,10 +149,6 @@ module Ci end end - def set_default_values - self.token = SecureRandom.hex(15) if self.token.blank? - end - def assign_to(project, current_user = nil) if instance_type? self.runner_type = :project_type diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index e43a0fd1786..421a923d386 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -56,7 +56,11 @@ module Clusters def specification { "ingress" => { - "hosts" => [hostname] + "hosts" => [hostname], + "tls" => [{ + "hosts" => [hostname], + "secretName" => "jupyter-cert" + }] }, "hub" => { "extraEnv" => { diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index c0aaa8dce20..168a24da738 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -15,6 +15,9 @@ module Clusters include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData include AfterCommitQueue + include ReactiveCaching + + self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] } state_machine :status do before_transition any => [:installed] do |application| @@ -29,6 +32,8 @@ module Clusters validates :hostname, presence: true, hostname: true + scope :for_cluster, -> (cluster) { where(cluster: cluster) } + def chart 'knative/knative' end @@ -55,12 +60,39 @@ module Clusters ClusterWaitForIngressIpAddressWorker.perform_async(name, id) end + def client + cluster.kubeclient.knative_client + end + + def services + with_reactive_cache do |data| + data[:services] + end + end + + def calculate_reactive_cache + { services: read_services } + end + def ingress_service cluster.kubeclient.get_service('knative-ingressgateway', 'istio-system') end - def client - cluster.platform_kubernetes.kubeclient.knative_client + def services_for(ns: namespace) + return unless services + return [] unless ns + + services.select do |service| + service.dig('metadata', 'namespace') == ns + end + end + + private + + def read_services + client.get_services.as_json + rescue Kubeclient::ResourceNotFoundError + [] end end end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 67746e34913..c931b340b24 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ActiveRecord::Base - VERSION = '0.1.38'.freeze + VERSION = '0.1.39'.freeze self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 13906c903b9..7fe43cd2de0 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -4,6 +4,7 @@ module Clusters class Cluster < ActiveRecord::Base include Presentable include Gitlab::Utils::StrongMemoize + include FromUnion self.table_name = 'clusters' @@ -86,6 +87,29 @@ module Clusters scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } + scope :missing_kubernetes_namespace, -> (kubernetes_namespaces) do + subquery = kubernetes_namespaces.select('1').where('clusters_kubernetes_namespaces.cluster_id = clusters.id') + + where('NOT EXISTS (?)', subquery) + end + + scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.installed) } + + scope :preload_knative, -> { + preload( + :kubernetes_namespace, + :platform_kubernetes, + :application_knative + ) + } + + def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc) + hierarchy_groups = clusterable.ancestors_upto(hierarchy_order: hierarchy_order).eager_load(:clusters) + hierarchy_groups = hierarchy_groups.merge(current_scope) if current_scope + + hierarchy_groups.flat_map(&:clusters) + end + def status_name if provider provider.status_name @@ -122,6 +146,16 @@ module Clusters !user? end + def all_projects + if project_type? + projects + elsif group_type? + first_group.all_projects + else + Project.none + end + end + def first_project strong_memoize(:first_project) do projects.first @@ -140,11 +174,17 @@ module Clusters platform_kubernetes.kubeclient if kubernetes? end - def find_or_initialize_kubernetes_namespace(cluster_project) - kubernetes_namespaces.find_or_initialize_by( - project: cluster_project.project, - cluster_project: cluster_project - ) + def find_or_initialize_kubernetes_namespace_for_project(project) + if project_type? + kubernetes_namespaces.find_or_initialize_by( + project: project, + cluster_project: cluster_project + ) + else + kubernetes_namespaces.find_or_initialize_by( + project: project + ) + end end def allow_user_defined_namespace? diff --git a/app/models/clusters/kubernetes_namespace.rb b/app/models/clusters/kubernetes_namespace.rb index 34f5e38ff79..73da6cb37d7 100644 --- a/app/models/clusters/kubernetes_namespace.rb +++ b/app/models/clusters/kubernetes_namespace.rb @@ -33,14 +33,12 @@ module Clusters end def predefined_variables - config = YAML.dump(kubeconfig) - Gitlab::Ci::Variables::Collection.new.tap do |variables| variables .append(key: 'KUBE_SERVICE_ACCOUNT', value: service_account_name.to_s) .append(key: 'KUBE_NAMESPACE', value: namespace.to_s) .append(key: 'KUBE_TOKEN', value: service_account_token.to_s, public: false) - .append(key: 'KUBECONFIG', value: config, public: false, file: true) + .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) end end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 3c5d7756eec..867f0edcb07 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -85,18 +85,16 @@ module Clusters if kubernetes_namespace = cluster.kubernetes_namespaces.has_service_account_token.find_by(project: project) variables.concat(kubernetes_namespace.predefined_variables) - else + elsif cluster.project_type? # From 11.5, every Clusters::Project should have at least one # Clusters::KubernetesNamespace, so once migration has been completed, # this 'else' branch will be removed. For more information, please see # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22433 - config = YAML.dump(kubeconfig) - variables .append(key: 'KUBE_URL', value: api_url) .append(key: 'KUBE_TOKEN', value: token, public: false) .append(key: 'KUBE_NAMESPACE', value: actual_namespace) - .append(key: 'KUBECONFIG', value: config, public: false, file: true) + .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) end end end diff --git a/app/models/commit.rb b/app/models/commit.rb index 546fcc54a15..a422a0995ff 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -177,7 +177,9 @@ class Commit def title return full_title if full_title.length < 100 - full_title.truncate(81, separator: ' ', omission: '…') + # Use three dots instead of the ellipsis Unicode character because + # some clients show the raw Unicode value in the merge commit. + full_title.truncate(81, separator: ' ', omission: '...') end # Returns the full commits title @@ -298,7 +300,7 @@ class Commit end def pipelines - project.pipelines.where(sha: sha) + project.ci_pipelines.where(sha: sha) end def last_pipeline @@ -312,7 +314,7 @@ class Commit end def status_for_project(ref, pipeline_project) - pipeline_project.pipelines.latest_status_per_commit(id, ref)[id] + pipeline_project.ci_pipelines.latest_status_per_commit(id, ref)[id] end def set_status_for_ref(ref, status) diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index dd93af9df64..e349f0fe971 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -24,7 +24,7 @@ class CommitCollection # Setting this status ahead of time removes the need for running a query for # every commit we're displaying. def with_pipeline_status - statuses = project.pipelines.latest_status_per_commit(map(&:id), ref) + statuses = project.ci_pipelines.latest_status_per_commit(map(&:id), ref) each do |commit| commit.set_status_for_ref(ref, statuses[commit.id]) diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index 60b7ec2815c..14bc56f0eee 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -43,14 +43,19 @@ module Awardable end def order_upvotes_desc - order_votes_desc(AwardEmoji::UPVOTE_NAME) + order_votes(AwardEmoji::UPVOTE_NAME, 'DESC') + end + + def order_upvotes_asc + order_votes(AwardEmoji::UPVOTE_NAME, 'ASC') end def order_downvotes_desc - order_votes_desc(AwardEmoji::DOWNVOTE_NAME) + order_votes(AwardEmoji::DOWNVOTE_NAME, 'DESC') end - def order_votes_desc(emoji_name) + # Order votes by emoji, optional sort order param `descending` defaults to true + def order_votes(emoji_name, direction) awardable_table = self.arel_table awards_table = AwardEmoji.arel_table @@ -62,7 +67,7 @@ module Awardable ) ).join_sources - joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) DESC") + joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) #{direction}") end end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 6e2adc76ec6..a8c9e54f00c 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -15,7 +15,7 @@ module CacheMarkdownField # Increment this number every time the renderer changes its output CACHE_REDCARPET_VERSION = 3 CACHE_COMMONMARK_VERSION_START = 10 - CACHE_COMMONMARK_VERSION = 11 + CACHE_COMMONMARK_VERSION = 12 # changes to these attributes cause the cache to be invalidates INVALIDATED_BY = %w[author project].freeze diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb index edf6ac96730..af4905115b1 100644 --- a/app/models/concerns/chronic_duration_attribute.rb +++ b/app/models/concerns/chronic_duration_attribute.rb @@ -24,7 +24,7 @@ module ChronicDurationAttribute end end - validates virtual_attribute, allow_nil: true, duration: true + validates virtual_attribute, allow_nil: true, duration: { message: parameters[:error_message] } end alias_method :chronic_duration_attr, :chronic_duration_attr_writer diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index e57a3383544..0107af5f8ec 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -13,6 +13,7 @@ module DeploymentPlatform def find_deployment_platform(environment) find_cluster_platform_kubernetes(environment: environment) || + find_group_cluster_platform_kubernetes_with_feature_guard(environment: environment) || find_kubernetes_service_integration || build_cluster_and_deployment_platform end @@ -23,6 +24,18 @@ module DeploymentPlatform .last&.platform_kubernetes end + def find_group_cluster_platform_kubernetes_with_feature_guard(environment: nil) + return unless group_clusters_enabled? + + find_group_cluster_platform_kubernetes(environment: environment) + end + + # EE would override this and utilize environment argument + def find_group_cluster_platform_kubernetes(environment: nil) + Clusters::Cluster.enabled.default_environment.ancestor_clusters_for_clusterable(self) + .first&.platform_kubernetes + end + def find_kubernetes_service_integration services.deployment.reorder(nil).find_by(active: true) end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index c180d7b7c9a..266c37fa3a1 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -38,12 +38,13 @@ module DiscussionOnDiff end # Returns an array of at most 16 highlighted lines above a diff note - def truncated_diff_lines(highlight: true) + def truncated_diff_lines(highlight: true, diff_limit: nil) return [] if diff_line.nil? && first_note.is_a?(LegacyDiffNote) + diff_limit = [diff_limit, NUMBER_OF_TRUNCATED_DIFF_LINES].compact.min lines = highlight ? highlighted_diff_lines : diff_lines - initial_line_index = [diff_line.index - NUMBER_OF_TRUNCATED_DIFF_LINES + 1, 0].max + initial_line_index = [diff_line.index - diff_limit + 1, 0].max prev_lines = [] diff --git a/app/models/concerns/fast_destroy_all.rb b/app/models/concerns/fast_destroy_all.rb index 2bfa7da6c1c..1e3afd641ed 100644 --- a/app/models/concerns/fast_destroy_all.rb +++ b/app/models/concerns/fast_destroy_all.rb @@ -70,13 +70,14 @@ module FastDestroyAll module Helpers extend ActiveSupport::Concern + include AfterCommitQueue class_methods do ## # This method is to be defined on models which have fast destroyable models as children, # and let us avoid to use `dependent: :destroy` hook - def use_fast_destroy(relation) - before_destroy(prepend: true) do + def use_fast_destroy(relation, opts = {}) + set_callback :destroy, :before, opts.merge(prepend: true) do perform_fast_destroy(public_send(relation)) # rubocop:disable GitlabSecurity/PublicSend end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 5080fe03cc8..0d363ec68b7 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -145,14 +145,16 @@ module Issuable def sort_by_attribute(method, excluded_labels: []) sorted = case method.to_s - when 'downvotes_desc' then order_downvotes_desc - when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels) - when 'milestone' then order_milestone_due_asc - when 'milestone_due_asc' then order_milestone_due_asc - when 'milestone_due_desc' then order_milestone_due_desc - when 'popularity' then order_upvotes_desc - when 'priority' then order_due_date_and_labels_priority(excluded_labels: excluded_labels) - when 'upvotes_desc' then order_upvotes_desc + when 'downvotes_desc' then order_downvotes_desc + when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels) + when 'label_priority_desc' then order_labels_priority('DESC', excluded_labels: excluded_labels) + when 'milestone', 'milestone_due_asc' then order_milestone_due_asc + when 'milestone_due_desc' then order_milestone_due_desc + when 'popularity', 'popularity_desc' then order_upvotes_desc + when 'popularity_asc' then order_upvotes_asc + when 'priority', 'priority_asc' then order_due_date_and_labels_priority(excluded_labels: excluded_labels) + when 'priority_desc' then order_due_date_and_labels_priority('DESC', excluded_labels: excluded_labels) + when 'upvotes_desc' then order_upvotes_desc else order_by(method) end @@ -160,7 +162,7 @@ module Issuable sorted.with_order_id_desc end - def order_due_date_and_labels_priority(excluded_labels: []) + def order_due_date_and_labels_priority(direction = 'ASC', excluded_labels: []) # The order_ methods also modify the query in other ways: # # - For milestones, we add a JOIN. @@ -177,11 +179,11 @@ module Issuable order_milestone_due_asc .order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date]) - .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, 'ASC'), - Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) + .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, direction), + Gitlab::Database.nulls_last_order('highest_priority', direction)) end - def order_labels_priority(excluded_labels: [], extra_select_columns: []) + def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: []) params = { target_type: name, target_column: "#{table_name}.id", @@ -198,7 +200,7 @@ module Issuable select(select_columns.join(', ')) .group(arel_table[:id]) - .reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) + .reorder(Gitlab::Database.nulls_last_order('highest_priority', direction)) end def with_label(title, sort = nil) diff --git a/app/models/concerns/shardable.rb b/app/models/concerns/shardable.rb new file mode 100644 index 00000000000..57cd77b44b4 --- /dev/null +++ b/app/models/concerns/shardable.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Shardable + extend ActiveSupport::Concern + + included do + belongs_to :shard + validates :shard, presence: true + end + + def shard_name + shard&.name + end + + def shard_name=(name) + self.shard = Shard.by_name(name) + end +end diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index 23a43aec677..f5bb559ceda 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -9,24 +9,18 @@ module TokenAuthenticatable private # rubocop:disable Lint/UselessAccessModifier def add_authentication_token_field(token_field, options = {}) - @token_fields = [] unless @token_fields - unique = options.fetch(:unique, true) - - if @token_fields.include?(token_field) + if token_authenticatable_fields.include?(token_field) raise ArgumentError.new("#{token_field} already configured via add_authentication_token_field") end - @token_fields << token_field + token_authenticatable_fields.push(token_field) attr_accessor :cleartext_tokens - strategy = if options[:digest] - TokenAuthenticatableStrategies::Digest.new(self, token_field, options) - else - TokenAuthenticatableStrategies::Insecure.new(self, token_field, options) - end + strategy = TokenAuthenticatableStrategies::Base + .fabricate(self, token_field, options) - if unique + if options.fetch(:unique, true) define_singleton_method("find_by_#{token_field}") do |token| strategy.find_token_authenticatable(token) end @@ -53,6 +47,15 @@ module TokenAuthenticatable define_method("reset_#{token_field}!") do strategy.reset_token!(self) end + + define_method("#{token_field}_matches?") do |other_token| + token = read_attribute(token_field) + token.present? && ActiveSupport::SecurityUtils.variable_size_secure_compare(other_token, token) + end + end + + def token_authenticatable_fields + @token_authenticatable_fields ||= [] end end end diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index 413721d3e6c..01fb194281a 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -2,6 +2,8 @@ module TokenAuthenticatableStrategies class Base + attr_reader :klass, :token_field, :options + def initialize(klass, token_field, options) @klass = klass @token_field = token_field @@ -22,6 +24,7 @@ module TokenAuthenticatableStrategies def ensure_token(instance) write_new_token(instance) unless token_set?(instance) + get_token(instance) end # Returns a token, but only saves when the database is in read & write mode @@ -36,6 +39,36 @@ module TokenAuthenticatableStrategies instance.save! if Gitlab::Database.read_write? end + def fallback? + unless options[:fallback].in?([true, false, nil]) + raise ArgumentError, 'fallback: needs to be a boolean value!' + end + + options[:fallback] == true + end + + def migrating? + unless options[:migrating].in?([true, false, nil]) + raise ArgumentError, 'migrating: needs to be a boolean value!' + end + + options[:migrating] == true + end + + def self.fabricate(model, field, options) + if options[:digest] && options[:encrypted] + raise ArgumentError, 'Incompatible options set!' + end + + if options[:digest] + TokenAuthenticatableStrategies::Digest.new(model, field, options) + elsif options[:encrypted] + TokenAuthenticatableStrategies::Encrypted.new(model, field, options) + else + TokenAuthenticatableStrategies::Insecure.new(model, field, options) + end + end + protected def write_new_token(instance) @@ -65,9 +98,5 @@ module TokenAuthenticatableStrategies def token_set?(instance) raise NotImplementedError end - - def token_field_name - @token_field - end end end diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb new file mode 100644 index 00000000000..152491aa6e9 --- /dev/null +++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module TokenAuthenticatableStrategies + class Encrypted < Base + def initialize(*) + super + + if migrating? && fallback? + raise ArgumentError, '`fallback` and `migrating` options are not compatible!' + end + end + + def find_token_authenticatable(token, unscoped = false) + return if token.blank? + + if fully_encrypted? + return find_by_encrypted_token(token, unscoped) + end + + if fallback? + find_by_encrypted_token(token, unscoped) || + find_by_plaintext_token(token, unscoped) + elsif migrating? + find_by_plaintext_token(token, unscoped) + else + raise ArgumentError, 'Unknown encryption phase!' + end + end + + def ensure_token(instance) + # TODO, tech debt, because some specs are testing migrations, but are still + # using factory bot to create resources, it might happen that a database + # schema does not have "#{token_name}_encrypted" field yet, however a bunch + # of models call `ensure_#{token_name}` in `before_save`. + # + # In that case we are using insecure strategy, but this should only happen + # in tests, because otherwise `encrypted_field` is going to exist. + # + # Another use case is when we are caching resources / columns, like we do + # in case of ApplicationSetting. + + return super if instance.has_attribute?(encrypted_field) + + if fully_encrypted? + raise ArgumentError, 'Using encrypted strategy when encrypted field is missing!' + else + insecure_strategy.ensure_token(instance) + end + end + + def get_token(instance) + return insecure_strategy.get_token(instance) if migrating? + + encrypted_token = instance.read_attribute(encrypted_field) + token = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) + + token || (insecure_strategy.get_token(instance) if fallback?) + end + + def set_token(instance, token) + raise ArgumentError unless token.present? + + instance[encrypted_field] = Gitlab::CryptoHelper.aes256_gcm_encrypt(token) + instance[token_field] = token if migrating? + instance[token_field] = nil if fallback? + token + end + + def fully_encrypted? + !migrating? && !fallback? + end + + protected + + def find_by_plaintext_token(token, unscoped) + insecure_strategy.find_token_authenticatable(token, unscoped) + end + + def find_by_encrypted_token(token, unscoped) + encrypted_value = Gitlab::CryptoHelper.aes256_gcm_encrypt(token) + relation(unscoped).find_by(encrypted_field => encrypted_value) + end + + def insecure_strategy + @insecure_strategy ||= TokenAuthenticatableStrategies::Insecure + .new(klass, token_field, options) + end + + def token_set?(instance) + raw_token = instance.read_attribute(encrypted_field) + + unless fully_encrypted? + raw_token ||= insecure_strategy.get_token(instance) + end + + raw_token.present? + end + + def encrypted_field + @encrypted_field ||= "#{@token_field}_encrypted" + end + end +end diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb index 2bdef2a40e4..d79c0eae77e 100644 --- a/app/models/concerns/with_uploads.rb +++ b/app/models/concerns/with_uploads.rb @@ -17,6 +17,8 @@ module WithUploads extend ActiveSupport::Concern + include FastDestroyAll::Helpers + include FeatureGate # Currently there is no simple way how to select only not-mounted # uploads, it should be all FileUploaders so we select them by @@ -25,21 +27,40 @@ module WithUploads included do has_many :uploads, as: :model + has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) }, class_name: 'Upload', as: :model - before_destroy :destroy_file_uploads + # TODO: when feature flag is removed, we can use just dependent: destroy + # option on :file_uploads + before_destroy :remove_file_uploads + + use_fast_destroy :file_uploads, if: :fast_destroy_enabled? + end + + def retrieve_upload(_identifier, paths) + uploads.find_by(path: paths) end + private + # mounted uploads are deleted in carrierwave's after_commit hook, # but FileUploaders which are not mounted must be deleted explicitly and # it can not be done in after_commit because FileUploader requires loads # associated model on destroy (which is already deleted in after_commit) - def destroy_file_uploads - self.uploads.where(uploader: FILE_UPLOADERS).find_each do |upload| + def remove_file_uploads + fast_destroy_enabled? ? delete_uploads : destroy_uploads + end + + def delete_uploads + file_uploads.delete_all(:delete_all) + end + + def destroy_uploads + file_uploads.find_each do |upload| upload.destroy end end - def retrieve_upload(_identifier, paths) - uploads.find_by(path: paths) + def fast_destroy_enabled? + Feature.enabled?(:fast_destroy_uploads, self) end end diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb index 7078496ff52..2fb6cadc8cd 100644 --- a/app/models/environment_status.rb +++ b/app/models/environment_status.rb @@ -8,16 +8,17 @@ class EnvironmentStatus delegate :id, to: :environment delegate :name, to: :environment delegate :project, to: :environment + delegate :status, to: :deployment, allow_nil: true delegate :deployed_at, to: :deployment, allow_nil: true def self.for_merge_request(mr, user) - build_environments_status(mr, user, mr.diff_head_sha) + build_environments_status(mr, user, mr.actual_head_pipeline) end def self.after_merge_request(mr, user) return [] unless mr.merged? - build_environments_status(mr, user, mr.merge_commit_sha) + build_environments_status(mr, user, mr.merge_pipeline) end def initialize(environment, merge_request, sha) @@ -43,22 +44,6 @@ class EnvironmentStatus .merge_request_diff_files.where(deleted_file: false) end - ## - # Since frontend has not supported all statuses yet, BE has to - # proxy some status to a supported status. - def status - return unless deployment - - case deployment.status - when 'created' - 'running' - when 'canceled' - 'failed' - else - deployment.status - end - end - private PAGE_EXTENSIONS = /\A\.(s?html?|php|asp|cgi|pl)\z/i.freeze @@ -76,13 +61,13 @@ class EnvironmentStatus } end - def self.build_environments_status(mr, user, sha) - Environment.where(project_id: [mr.source_project_id, mr.target_project_id]) - .available - .with_deployment(sha).map do |environment| + def self.build_environments_status(mr, user, pipeline) + return [] unless pipeline + + pipeline.environments.available.map do |environment| next unless Ability.allowed?(user, :read_environment, environment) - EnvironmentStatus.new(environment, mr, sha) + EnvironmentStatus.new(environment, mr, pipeline.sha) end.compact end private_class_method :build_environments_status diff --git a/app/models/group.rb b/app/models/group.rb index adb9169cfcd..233747cc2c2 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -55,7 +55,7 @@ class Group < Namespace validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } - add_authentication_token_field :runners_token + add_authentication_token_field :runners_token, encrypted: true, migrating: true after_create :post_create_hook after_destroy :post_destroy_hook @@ -400,6 +400,10 @@ class Group < Namespace ensure_runners_token! end + def group_clusters_enabled? + Feature.enabled?(:group_clusters, root_ancestor, default_enabled: true) + end + private def update_two_factor_requirement diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index b2fb79bc7ed..1a8662db9fb 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -6,12 +6,12 @@ class WebHook < ActiveRecord::Base attr_encrypted :token, mode: :per_attribute_iv, algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_truncated + key: Settings.attr_encrypted_db_key_base_32 attr_encrypted :url, mode: :per_attribute_iv, algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_truncated + key: Settings.attr_encrypted_db_key_base_32 has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent diff --git a/app/models/issue.rb b/app/models/issue.rb index 780035c77e2..b7e13bcbccf 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -235,20 +235,6 @@ class Issue < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - if options.key?(:issue_endpoints) && project - url_helper = Gitlab::Routing.url_helpers - - issue_reference = options[:include_full_project_path] ? to_reference(full: true) : to_reference - - json.merge!( - reference_path: issue_reference, - real_path: url_helper.project_issue_path(project, self), - issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'), - toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self), - assignable_labels_endpoint: url_helper.project_labels_path(project, format: :json, include_ancestor_groups: true) - ) - end - if options.key?(:labels) json[:labels] = labels.as_json( project: project, diff --git a/app/models/member.rb b/app/models/member.rb index bc8ac14d148..9fc95ea00c3 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -7,6 +7,7 @@ class Member < ActiveRecord::Base include Expirable include Gitlab::Access include Presentable + include Gitlab::Utils::StrongMemoize attr_accessor :raw_invite_token @@ -22,6 +23,7 @@ class Member < ActiveRecord::Base message: "already exists in source", allow_nil: true } validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true + validate :higher_access_level_than_group, unless: :importing? validates :invite_email, presence: { if: :invite? @@ -364,6 +366,15 @@ class Member < ActiveRecord::Base end # rubocop: enable CodeReuse/ServiceClass + # Find the user's group member with a highest access level + def highest_group_member + strong_memoize(:highest_group_member) do + next unless user_id && source&.ancestors&.any? + + GroupMember.where(source: source.ancestors, user_id: user_id).order(:access_level).last + end + end + private def send_invite @@ -430,4 +441,12 @@ class Member < ActiveRecord::Base def notifiable_options {} end + + def higher_access_level_than_group + if highest_group_member && highest_group_member.access_level >= access_level + error_parameters = { access: highest_group_member.human_access, group_name: highest_group_member.group.name } + + errors.add(:access_level, s_("should be higher than %{access} inherited membership from group %{group_name}") % error_parameters) + end + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 92add079a02..861211ffc0a 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -63,6 +63,7 @@ class MergeRequest < ActiveRecord::Base dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :cached_closes_issues, through: :merge_requests_closing_issues, source: :issue + has_many :merge_request_pipelines, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline' belongs_to :assignee, class_name: "User" @@ -538,15 +539,26 @@ class MergeRequest < ActiveRecord::Base def validate_branches if target_project == source_project && target_branch == source_branch - errors.add :branch_conflict, "You can not use same project/branch for source and target" + errors.add :branch_conflict, "You can't use same project/branch for source and target" + return end if opened? - similar_mrs = self.target_project.merge_requests.where(source_branch: source_branch, target_branch: target_branch, source_project_id: source_project.try(:id)).opened - similar_mrs = similar_mrs.where('id not in (?)', self.id) if self.id - if similar_mrs.any? - errors.add :validate_branches, - "Cannot Create: This merge request already exists: #{similar_mrs.pluck(:title)}" + similar_mrs = target_project + .merge_requests + .where(source_branch: source_branch, target_branch: target_branch) + .where(source_project_id: source_project&.id) + .opened + + similar_mrs = similar_mrs.where.not(id: id) if persisted? + + conflict = similar_mrs.first + + if conflict.present? + errors.add( + :validate_branches, + "Another open merge request already exists for this source branch: #{conflict.to_reference}" + ) end end end @@ -1052,18 +1064,59 @@ class MergeRequest < ActiveRecord::Base diverged_commits_count > 0 end - def all_pipelines + def all_pipelines(shas: all_commit_shas) return Ci::Pipeline.none unless source_project - @all_pipelines ||= source_project.pipelines - .where(sha: all_commit_shas, ref: source_branch) - .order(id: :desc) + @all_pipelines ||= source_project.ci_pipelines + .where(sha: shas, ref: source_branch) + .where(merge_request: [nil, self]) + .sort_by_merge_request_pipelines + end + + def merge_request_pipeline_exists? + merge_request_pipelines.exists?(sha: diff_head_sha) end def has_test_reports? actual_head_pipeline&.has_test_reports? end + def predefined_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_IID', value: iid.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_REF_PATH', + value: ref_path.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID', + value: project.id.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', + value: project.full_path) + + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', + value: project.web_url) + + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', + value: target_branch.to_s) + + if source_project + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID', + value: source_project.id.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', + value: source_project.full_path) + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', + value: source_project.web_url) + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', + value: source_branch.to_s) + end + end + end + # rubocop: disable CodeReuse/ServiceClass def compare_test_reports unless has_test_reports? @@ -1214,7 +1267,7 @@ class MergeRequest < ActiveRecord::Base end def base_pipeline - @base_pipeline ||= project.pipelines + @base_pipeline ||= project.ci_pipelines .order(id: :desc) .find_by(sha: diff_base_sha) end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 11b03846f0b..8865c164b11 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -192,9 +192,9 @@ class Namespace < ActiveRecord::Base # returns all ancestors upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned - def ancestors_upto(top = nil) + def ancestors_upto(top = nil, hierarchy_order: nil) Gitlab::GroupHierarchy.new(self.class.where(id: id)) - .ancestors(upto: top) + .ancestors(upto: top, hierarchy_order: hierarchy_order) end def self_and_ancestors @@ -243,7 +243,7 @@ class Namespace < ActiveRecord::Base end def root_ancestor - ancestors.reorder(nil).find_by(parent_id: nil) + self_and_ancestors.reorder(nil).find_by(parent_id: nil) end def subgroup? diff --git a/app/models/note.rb b/app/models/note.rb index 592efb714f3..a6ae4f58ac4 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -324,7 +324,7 @@ class Note < ActiveRecord::Base end def to_ability_name - for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore + for_snippet? ? noteable.class.name.underscore : noteable_type.underscore end def can_be_discussion_note? diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 1600acfc575..e82eaf4e069 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -5,7 +5,7 @@ class NotificationSetting < ActiveRecord::Base ignore_column :events - enum level: { global: 3, watch: 2, mention: 4, participating: 1, disabled: 0, custom: 5 } + enum level: { global: 3, watch: 2, participating: 1, mention: 4, disabled: 0, custom: 5 } default_value_for :level, NotificationSetting.levels[:global] diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index 8ef74539209..bad0e30ceb5 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -1,22 +1,20 @@ # frozen_string_literal: true class PoolRepository < ActiveRecord::Base - POOL_PREFIX = '@pools' + include Shardable - belongs_to :shard - validates :shard, presence: true + has_many :member_projects, class_name: 'Project' - # For now, only pool repositories are tracked in the database. However, we may - # want to add other repository types in the future - self.table_name = 'repositories' + after_create :correct_disk_path - has_many :pool_member_projects, class_name: 'Project', foreign_key: :pool_repository_id + private - def shard_name - shard&.name + def correct_disk_path + update!(disk_path: storage.disk_path) end - def shard_name=(name) - self.shard = Shard.by_name(name) + def storage + Storage::HashedProject + .new(self, prefix: Storage::HashedProject::POOL_PATH_PREFIX) end end diff --git a/app/models/project.rb b/app/models/project.rb index 39978d8a4c4..9e736a3b03c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -30,6 +30,7 @@ class Project < ActiveRecord::Base include FeatureGate include OptionallySearch include FromUnion + include IgnorableColumn extend Gitlab::Cache::RequestCache extend Gitlab::ConfigHelper @@ -55,6 +56,8 @@ class Project < ActiveRecord::Base VALID_MIRROR_PORTS = [22, 80, 443].freeze VALID_MIRROR_PROTOCOLS = %w(http https ssh git).freeze + ignore_column :import_status, :import_jid, :import_error + cache_markdown_field :description, pipeline: :description delegate :feature_available?, :builds_enabled?, :wiki_enabled?, @@ -63,6 +66,12 @@ class Project < ActiveRecord::Base delegate :base_dir, :disk_path, :ensure_storage_path_exists, to: :storage + delegate :scheduled?, :started?, :in_progress?, + :failed?, :finished?, + prefix: :import, to: :import_state, allow_nil: true + + delegate :no_import?, to: :import_state, allow_nil: true + default_value_for :archived, false default_value_for :visibility_level, gitlab_config_features.visibility_level default_value_for :resolve_outdated_diff_discussions, false @@ -76,7 +85,7 @@ class Project < ActiveRecord::Base default_value_for :snippets_enabled, gitlab_config_features.snippets default_value_for :only_allow_merge_if_all_discussions_are_resolved, false - add_authentication_token_field :runners_token + add_authentication_token_field :runners_token, encrypted: true, migrating: true before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? } @@ -177,6 +186,7 @@ class Project < ActiveRecord::Base has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_one :project_repository, inverse_of: :project # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project @@ -228,6 +238,7 @@ class Project < ActiveRecord::Base has_one :cluster_project, class_name: 'Clusters::Project' has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' has_many :cluster_ingresses, through: :clusters, source: :application_ingress, class_name: 'Clusters::Applications::Ingress' + has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace' has_many :prometheus_metrics @@ -237,7 +248,17 @@ class Project < ActiveRecord::Base has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :commit_statuses - has_many :pipelines, class_name: 'Ci::Pipeline', inverse_of: :project + # The relation :all_pipelines is intented to be used when we want to get the + # whole list of pipelines associated to the project + has_many :all_pipelines, class_name: 'Ci::Pipeline', inverse_of: :project + # The relation :ci_pipelines is intented to be used when we want to get only + # those pipeline which are directly related to CI. There are + # other pipelines, like webide ones, that we won't retrieve + # if we use this relation. + has_many :ci_pipelines, + -> { Feature.enabled?(:pipeline_ci_sources_only, default_enabled: true) ? ci_sources : all }, + class_name: 'Ci::Pipeline', + inverse_of: :project has_many :stages, class_name: 'Ci::Stage', inverse_of: :project # Ci::Build objects store data on the file system such as artifact files and @@ -280,6 +301,8 @@ class Project < ActiveRecord::Base delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, to: :team delegate :add_master, to: :team # @deprecated delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings + delegate :group_clusters_enabled?, to: :group, allow_nil: true + delegate :root_ancestor, to: :namespace, allow_nil: true # Validations validates :creator, presence: true, on: :create @@ -316,6 +339,7 @@ class Project < ActiveRecord::Base presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } validates :variables, variable_duplicates: { scope: :environment_scope } + validates :bfg_object_map, file_size: { maximum: :max_attachment_size } # Scopes scope :pending_delete, -> { where(pending_delete: true) } @@ -372,9 +396,16 @@ class Project < ActiveRecord::Base .where(project_ci_cd_settings: { group_runners_enabled: true }) end + scope :missing_kubernetes_namespace, -> (kubernetes_namespaces) do + subquery = kubernetes_namespaces.select('1').where('clusters_kubernetes_namespaces.project_id = projects.id') + + where('NOT EXISTS (?)', subquery) + end + enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } - chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600 + chronic_duration_attr :build_timeout_human_readable, :build_timeout, + default: 3600, error_message: 'Maximum job timeout has a value which could not be accepted' validates :build_timeout, allow_nil: true, numericality: { greater_than_or_equal_to: 10.minutes, @@ -382,6 +413,9 @@ class Project < ActiveRecord::Base only_integer: true, message: 'needs to be beetween 10 minutes and 1 month' } + # Used by Projects::CleanupService to hold a map of rewritten object IDs + mount_uploader :bfg_object_map, AttachmentUploader + # Returns a project, if it is not about to be removed. # # id - The ID of the project to retrieve. @@ -451,8 +485,8 @@ class Project < ActiveRecord::Base scope :excluding_project, ->(project) { where.not(id: project) } - scope :joins_import_state, -> { joins("LEFT JOIN project_mirror_data import_state ON import_state.project_id = projects.id") } - scope :import_started, -> { joins_import_state.where("import_state.status = 'started' OR projects.import_status = 'started'") } + # We require an alias to the project_mirror_data_table in order to use import_state in our queries + scope :joins_import_state, -> { joins("INNER JOIN project_mirror_data import_state ON import_state.project_id = projects.id") } scope :for_group, -> (group) { where(group: group) } class << self @@ -535,11 +569,13 @@ class Project < ActiveRecord::Base # returns all ancestor-groups upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned - def ancestors_upto(top = nil) + def ancestors_upto(top = nil, hierarchy_order: nil) Gitlab::GroupHierarchy.new(Group.where(id: namespace_id)) - .base_and_ancestors(upto: top) + .base_and_ancestors(upto: top, hierarchy_order: hierarchy_order) end + alias_method :ancestors, :ancestors_upto + def lfs_enabled? return namespace.lfs_enabled? if self[:lfs_enabled].nil? @@ -610,7 +646,7 @@ class Project < ActiveRecord::Base # ref can't be HEAD, can only be branch/tag name or SHA def latest_successful_builds_for(ref = default_branch) - latest_pipeline = pipelines.latest_successful_for(ref) + latest_pipeline = ci_pipelines.latest_successful_for(ref) if latest_pipeline latest_pipeline.builds.latest.with_artifacts_archive @@ -628,6 +664,14 @@ class Project < ActiveRecord::Base id && persisted? end + def import_status + import_state&.status || 'none' + end + + def human_import_status_name + import_state&.human_status_name || 'none' + end + def add_import_job job_id = if forked? @@ -659,7 +703,7 @@ class Project < ActiveRecord::Base ProjectCacheWorker.perform_async(self.id) end - update(import_error: nil) + import_state.update(last_error: nil) remove_import_data end @@ -721,130 +765,6 @@ class Project < ActiveRecord::Base import_url.present? end - def imported? - import_finished? - end - - def import_in_progress? - import_started? || import_scheduled? - end - - def import_state_args - { - status: self[:import_status], - jid: self[:import_jid], - last_error: self[:import_error] - } - end - - def ensure_import_state(force: false) - return if !force && (self[:import_status] == 'none' || self[:import_status].nil?) - return unless import_state.nil? - - if persisted? - create_import_state(import_state_args) - - update_column(:import_status, 'none') - else - build_import_state(import_state_args) - - self[:import_status] = 'none' - end - end - - def human_import_status_name - ensure_import_state - - import_state.human_status_name - end - - def import_schedule - ensure_import_state(force: true) - - import_state.schedule - end - - def force_import_start - ensure_import_state(force: true) - - import_state.force_start - end - - def import_start - ensure_import_state(force: true) - - import_state.start - end - - def import_fail - ensure_import_state(force: true) - - import_state.fail_op - end - - def import_finish - ensure_import_state(force: true) - - import_state.finish - end - - def import_jid=(new_jid) - ensure_import_state(force: true) - - import_state.jid = new_jid - end - - def import_jid - ensure_import_state - - import_state&.jid - end - - def import_error=(new_error) - ensure_import_state(force: true) - - import_state.last_error = new_error - end - - def import_error - ensure_import_state - - import_state&.last_error - end - - def import_status=(new_status) - ensure_import_state(force: true) - - import_state.status = new_status - end - - def import_status - ensure_import_state - - import_state&.status || 'none' - end - - def no_import? - import_status == 'none' - end - - def import_started? - # import? does SQL work so only run it if it looks like there's an import running - import_status == 'started' && import? - end - - def import_scheduled? - import_status == 'scheduled' - end - - def import_failed? - import_status == 'failed' - end - - def import_finished? - import_status == 'finished' - end - def safe_import_url Gitlab::UrlSanitizer.new(import_url).masked_url end @@ -985,9 +905,9 @@ class Project < ActiveRecord::Base end def readme_url - readme = repository.readme - if readme - Gitlab::Routing.url_helpers.project_blob_url(self, File.join(default_branch, readme.path)) + readme_path = repository.readme_path + if readme_path + Gitlab::Routing.url_helpers.project_blob_url(self, File.join(default_branch, readme_path)) end end @@ -1166,6 +1086,12 @@ class Project < ActiveRecord::Base path end + def all_clusters + group_clusters = Clusters::Cluster.joins(:groups).where(cluster_groups: { group_id: ancestors_upto } ) + + Clusters::Cluster.from_union([clusters, group_clusters]) + end + def items_for(entity) case entity when 'issue' then @@ -1246,6 +1172,11 @@ class Project < ActiveRecord::Base "#{web_url}.git" end + # Is overriden in EE + def lfs_http_url_to_repo(_) + http_url_to_repo + end + def forked? fork_network && fork_network.root_project != self end @@ -1313,6 +1244,13 @@ class Project < ActiveRecord::Base false end + def track_project_repository + return unless hashed_storage?(:repository) + + project_repo = project_repository || build_project_repository + project_repo.update!(shard_name: repository_storage, disk_path: disk_path) + end + def create_repository(force: false) # Forked import is handled asynchronously return if forked? && !force @@ -1469,7 +1407,7 @@ class Project < ActiveRecord::Base return unless sha - pipelines.order(id: :desc).find_by(sha: sha, ref: ref) + ci_pipelines.order(id: :desc).find_by(sha: sha, ref: ref) end def latest_successful_pipeline_for_default_branch @@ -1478,12 +1416,12 @@ class Project < ActiveRecord::Base end @latest_successful_pipeline_for_default_branch = - pipelines.latest_successful_for(default_branch) + ci_pipelines.latest_successful_for(default_branch) end def latest_successful_pipeline_for(ref = nil) if ref && ref != default_branch - pipelines.latest_successful_for(ref) + ci_pipelines.latest_successful_for(ref) else latest_successful_pipeline_for_default_branch end @@ -1643,8 +1581,8 @@ class Project < ActiveRecord::Base def after_import repository.after_import wiki.repository.after_import - import_finish - remove_import_jid + import_state.finish + import_state.remove_jid update_project_counter_caches after_create_default_branch refresh_markdown_cache! @@ -1684,32 +1622,11 @@ class Project < ActiveRecord::Base end # rubocop: enable CodeReuse/ServiceClass - def remove_import_jid - return unless import_jid - - Gitlab::SidekiqStatus.unset(import_jid) - - import_state.update_column(:jid, nil) - end - # Lazy loading of the `pipeline_status` attribute def pipeline_status @pipeline_status ||= Gitlab::Cache::Ci::ProjectPipelineStatus.load_for_project(self) end - def mark_import_as_failed(error_message) - original_errors = errors.dup - sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message) - - import_fail - - import_state.update_column(:last_error, sanitized_message) - rescue ActiveRecord::ActiveRecordError => e - Rails.logger.error("Error setting import status to failed: #{e.message}. Original error: #{sanitized_message}") - ensure - @errors = original_errors - end - 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) @@ -1986,17 +1903,6 @@ class Project < ActiveRecord::Base Gitlab::ReferenceCounter.new(gl_repository(is_wiki: wiki)) end - # Refreshes the expiration time of the associated import job ID. - # - # This method can be used by asynchronous importers to refresh the status, - # preventing the StuckImportJobsWorker from marking the import as failed. - def refresh_import_jid_expiration - return unless import_jid - - Gitlab::SidekiqStatus - .set(import_jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION) - end - def badges return project_badges unless group @@ -2071,6 +1977,10 @@ class Project < ActiveRecord::Base Ability.allowed?(user, :read_project_snippet, self) end + def max_attachment_size + Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i + end + private def use_hashed_storage diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index 7126bb66d80..488f0cb5971 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -69,4 +69,33 @@ class ProjectImportState < ActiveRecord::Base ensure @errors = original_errors end + + alias_method :no_import?, :none? + + def in_progress? + scheduled? || started? + end + + def started? + # import? does SQL work so only run it if it looks like there's an import running + status == 'started' && project.import? + end + + def remove_jid + return unless jid + + Gitlab::SidekiqStatus.unset(jid) + + update_column(:jid, nil) + end + + # Refreshes the expiration time of the associated import job ID. + # + # This method can be used by asynchronous importers to refresh the status, + # preventing the StuckImportJobsWorker from marking the import as failed. + def refresh_jid_expiration + return unless jid + + Gitlab::SidekiqStatus.set(jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION) + end end diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb new file mode 100644 index 00000000000..38913f3f2f5 --- /dev/null +++ b/app/models/project_repository.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ProjectRepository < ActiveRecord::Base + include Shardable + + belongs_to :project, inverse_of: :project_repository + + class << self + def find_project(disk_path) + find_by(disk_path: disk_path)&.project + end + end +end diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index c52a531e5fe..b801fd84a07 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -110,14 +110,12 @@ class KubernetesService < DeploymentService # Clusters::Platforms::Kubernetes, it won't be used on this method # as it's only needed for Clusters::Cluster. def predefined_variables(project:) - config = YAML.dump(kubeconfig) - Gitlab::Ci::Variables::Collection.new.tap do |variables| variables .append(key: 'KUBE_URL', value: api_url) .append(key: 'KUBE_TOKEN', value: token, public: false) .append(key: 'KUBE_NAMESPACE', value: actual_namespace) - .append(key: 'KUBECONFIG', value: config, public: false, file: true) + .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) if ca_pem.present? variables diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index 6f39a5e6e83..d60a6a7efa3 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -38,11 +38,11 @@ class PipelinesEmailService < Service end def can_test? - project.pipelines.any? + project.ci_pipelines.any? end def test_data(project, user) - data = Gitlab::DataBuilder::Pipeline.build(project.pipelines.last) + data = Gitlab::DataBuilder::Pipeline.build(project.ci_pipelines.last) data[:user] = user.hook_attrs data end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 211e5c3fcbf..60cb2d380d5 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -71,7 +71,7 @@ class PrometheusService < MonitoringService end def prometheus_client - RestClient::Resource.new(api_url) if api_url && manual_configuration? && active? + RestClient::Resource.new(api_url, max_redirects: 0) if api_url && manual_configuration? && active? end def prometheus_available? diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index a3415a4a14c..b7b4d0f1be9 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -15,8 +15,6 @@ class RemoteMirror < ActiveRecord::Base insecure_mode: true, algorithm: 'aes-256-cbc' - default_value_for :only_protected_branches, true - belongs_to :project, inverse_of: :remote_mirrors validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true } diff --git a/app/models/repository.rb b/app/models/repository.rb index 427dac99b79..35dd120856d 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -35,7 +35,7 @@ class Repository # # For example, for entry `:commit_count` there's a method called `commit_count` which # stores its data in the `commit_count` cache key. - CACHED_METHODS = %i(size commit_count rendered_readme contribution_guide + CACHED_METHODS = %i(size commit_count rendered_readme readme_path contribution_guide changelog license_blob license_key gitignore gitlab_ci_yml branch_names tag_names branch_count tag_count avatar exists? root_ref has_visible_content? @@ -48,7 +48,7 @@ class Repository # changed. This Hash maps file types (as returned by Gitlab::FileDetector) to # the corresponding methods to call for refreshing caches. METHOD_CACHES_FOR_FILE_TYPES = { - readme: :rendered_readme, + readme: %i(rendered_readme readme_path), changelog: :changelog, license: %i(license_blob license_key license), contributing: :contribution_guide, @@ -591,6 +591,11 @@ class Repository head_tree&.readme end + def readme_path + readme&.path + end + cache_method :readme_path + def rendered_readme return unless readme diff --git a/app/models/shard.rb b/app/models/shard.rb index 2e75bc91df0..e39d4232486 100644 --- a/app/models/shard.rb +++ b/app/models/shard.rb @@ -18,7 +18,9 @@ class Shard < ActiveRecord::Base end def self.by_name(name) - find_or_create_by(name: name) + transaction(requires_new: true) do + find_or_create_by(name: name) + end rescue ActiveRecord::RecordNotUnique retry end diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb index 90710f73fd3..911fb7e9ce9 100644 --- a/app/models/storage/hashed_project.rb +++ b/app/models/storage/hashed_project.rb @@ -5,17 +5,19 @@ module Storage attr_accessor :project delegate :gitlab_shell, :repository_storage, to: :project - ROOT_PATH_PREFIX = '@hashed'.freeze + REPOSITORY_PATH_PREFIX = '@hashed' + POOL_PATH_PREFIX = '@pools' - def initialize(project) + def initialize(project, prefix: REPOSITORY_PATH_PREFIX) @project = project + @prefix = prefix end # Base directory # # @return [String] directory where repository is stored def base_dir - "#{ROOT_PATH_PREFIX}/#{disk_hash[0..1]}/#{disk_hash[2..3]}" if disk_hash + "#{@prefix}/#{disk_hash[0..1]}/#{disk_hash[2..3]}" if disk_hash end # Disk path is used to build repository and project's wiki path on disk diff --git a/app/models/upload.rb b/app/models/upload.rb index e01e9c6a4f0..20860f14b83 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -25,6 +25,25 @@ class Upload < ActiveRecord::Base Digest::SHA256.file(path).hexdigest end + class << self + ## + # FastDestroyAll concerns + def begin_fast_destroy + { + Uploads::Local => Uploads::Local.new.keys(with_files_stored_locally), + Uploads::Fog => Uploads::Fog.new.keys(with_files_stored_remotely) + } + end + + ## + # FastDestroyAll concerns + def finalize_fast_destroy(keys) + keys.each do |store_class, paths| + store_class.new.delete_keys_async(paths) + end + end + end + def absolute_path raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local? return path unless relative_path? diff --git a/app/models/uploads/base.rb b/app/models/uploads/base.rb new file mode 100644 index 00000000000..f9814159958 --- /dev/null +++ b/app/models/uploads/base.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Uploads + class Base + BATCH_SIZE = 100 + + attr_reader :logger + + def initialize(logger: nil) + @logger ||= Rails.logger + end + + def delete_keys_async(keys_to_delete) + keys_to_delete.each_slice(BATCH_SIZE) do |batch| + DeleteStoredFilesWorker.perform_async(self.class, batch) + end + end + end +end diff --git a/app/models/uploads/fog.rb b/app/models/uploads/fog.rb new file mode 100644 index 00000000000..b44e273e9ab --- /dev/null +++ b/app/models/uploads/fog.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Uploads + class Fog < Base + include ::Gitlab::Utils::StrongMemoize + + def available? + object_store.enabled + end + + def keys(relation) + return [] unless available? + + relation.pluck(:path) + end + + def delete_keys(keys) + keys.each do |key| + connection.delete_object(bucket_name, key) + end + end + + private + + def object_store + Gitlab.config.uploads.object_store + end + + def bucket_name + return unless available? + + object_store.remote_directory + end + + def connection + return unless available? + + strong_memoize(:connection) do + ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys) + end + end + end +end diff --git a/app/models/uploads/local.rb b/app/models/uploads/local.rb new file mode 100644 index 00000000000..2901c33c359 --- /dev/null +++ b/app/models/uploads/local.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Uploads + class Local < Base + def keys(relation) + relation.includes(:model).find_each.map(&:absolute_path) + end + + def delete_keys(keys) + keys.each do |path| + delete_file(path) + end + end + + private + + def delete_file(path) + unless exists?(path) + logger.warn("File '#{path}' doesn't exist, skipping") + return + end + + unless in_uploads?(path) + message = "Path '#{path}' is not in uploads dir, skipping" + logger.warn(message) + Gitlab::Sentry.track_exception(RuntimeError.new(message), extra: { uploads_dir: storage_dir }) + return + end + + FileUtils.rm(path) + delete_dir!(File.dirname(path)) + end + + def exists?(path) + path.present? && File.exist?(path) + end + + def in_uploads?(path) + path.start_with?(storage_dir) + end + + def delete_dir!(path) + Dir.rmdir(path) + rescue Errno::ENOENT + # Ignore: path does not exist + rescue Errno::ENOTDIR + # Ignore: path is not a dir + rescue Errno::ENOTEMPTY, Errno::EEXIST + # Ignore: dir is not empty + end + + def storage_dir + @storage_dir ||= File.realpath(Gitlab.config.uploads.storage_path) + end + end +end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 7769c3d71c0..b1d6d461928 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -85,6 +85,12 @@ class WikiPage alias_method :to_param, :slug + def human_title + return 'Home' if title == 'home' + + title + end + # The formatted title of this page. def title if @attributes[:title] diff --git a/app/policies/commit_policy.rb b/app/policies/commit_policy.rb index 67e9bc12804..4d4f0ba9267 100644 --- a/app/policies/commit_policy.rb +++ b/app/policies/commit_policy.rb @@ -2,4 +2,6 @@ class CommitPolicy < BasePolicy delegate { @subject.project } + + rule { can?(:download_code) }.enable :read_commit end diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index bbc2b48b856..f22843b6463 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -9,8 +9,17 @@ class NotePolicy < BasePolicy condition(:editable, scope: :subject) { @subject.editable? } + condition(:can_read_noteable) { can?(:"read_#{@subject.to_ability_name}") } + rule { ~editable }.prevent :admin_note + # If user can't read the issue/MR/etc then they should not be allowed to do anything to their own notes + rule { ~can_read_noteable }.policy do + prevent :read_note + prevent :admin_note + prevent :resolve_note + end + rule { is_author }.policy do enable :read_note enable :admin_note diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb index d963c188559..ef6bbc0d109 100644 --- a/app/presenters/group_clusterable_presenter.rb +++ b/app/presenters/group_clusterable_presenter.rb @@ -31,6 +31,6 @@ class GroupClusterablePresenter < ClusterablePresenter override :learn_more_link def learn_more_link - link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') + link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/group/clusters/index'), target: '_blank', rel: 'noopener noreferrer') end end diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb index 2497bea4aff..9e9b6973b8e 100644 --- a/app/presenters/member_presenter.rb +++ b/app/presenters/member_presenter.rb @@ -7,6 +7,14 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated member.class.access_level_roles end + def valid_level_roles + return access_level_roles unless member.highest_group_member + + access_level_roles.reject do |_name, level| + member.highest_group_member.access_level > level + end + end + def can_resend_invite? invite? && can?(current_user, admin_member_permission, source) diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index d61124fa787..9bd64ea217e 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -6,27 +6,27 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated include GitlabRoutingHelper include StorageHelper include TreeHelper + include IconsHelper include ChecksCollaboration include Gitlab::Utils::StrongMemoize presents :project - AnchorData = Struct.new(:enabled, :label, :link, :class_modifier) + AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon) MAX_TAGS_TO_SHOW = 3 + def statistic_icon(icon_name = 'plus-square-o') + sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4') + end + def statistics_anchors(show_auto_devops_callout:) [ - readme_anchor_data, - changelog_anchor_data, - contribution_guide_anchor_data, - files_anchor_data, + license_anchor_data, commits_anchor_data, branches_anchor_data, tags_anchor_data, - gitlab_ci_anchor_data, - autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout), - kubernetes_cluster_anchor_data - ].compact.select { |item| item.enabled } + files_anchor_data + ].compact.select(&:is_link) end def statistics_buttons(show_auto_devops_callout:) @@ -37,27 +37,28 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout), kubernetes_cluster_anchor_data, gitlab_ci_anchor_data - ].compact.reject { |item| item.enabled } + ].compact.reject(&:is_link) end def empty_repo_statistics_anchors [ - files_anchor_data, + license_anchor_data, commits_anchor_data, branches_anchor_data, tags_anchor_data, - autodevops_anchor_data, - kubernetes_cluster_anchor_data - ].compact.select { |item| item.enabled } + files_anchor_data + ].compact.select { |item| item.is_link } end def empty_repo_statistics_buttons [ new_file_anchor_data, readme_anchor_data, + changelog_anchor_data, + contribution_guide_anchor_data, autodevops_anchor_data, kubernetes_cluster_anchor_data - ].compact.reject { |item| item.enabled } + ].compact.reject { |item| item.is_link } end def default_view @@ -113,7 +114,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def add_contribution_guide_path - add_special_file_path(file_name: 'CONTRIBUTING.md', commit_message: 'Add contribution guide') + add_special_file_path(file_name: 'CONTRIBUTING.md', commit_message: 'Add CONTRIBUTING') end def add_ci_yml_path @@ -149,32 +150,52 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def files_anchor_data AnchorData.new(true, - _('Files (%{human_size})') % { human_size: storage_counter(statistics.total_repository_size) }, + statistic_icon('doc-code') + + _('%{strong_start}%{human_size}%{strong_end} Files').html_safe % { + human_size: storage_counter(statistics.total_repository_size), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, empty_repo? ? nil : project_tree_path(project)) end def commits_anchor_data AnchorData.new(true, - n_('Commit (%{commit_count})', 'Commits (%{commit_count})', statistics.commit_count) % { commit_count: number_with_delimiter(statistics.commit_count) }, + statistic_icon('commit') + + n_('%{strong_start}%{commit_count}%{strong_end} Commit', '%{strong_start}%{commit_count}%{strong_end} Commits', statistics.commit_count).html_safe % { + commit_count: number_with_delimiter(statistics.commit_count), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, empty_repo? ? nil : project_commits_path(project, repository.root_ref)) end def branches_anchor_data AnchorData.new(true, - n_('Branch (%{branch_count})', 'Branches (%{branch_count})', repository.branch_count) % { branch_count: number_with_delimiter(repository.branch_count) }, + statistic_icon('branch') + + n_('%{strong_start}%{branch_count}%{strong_end} Branch', '%{strong_start}%{branch_count}%{strong_end} Branches', repository.branch_count).html_safe % { + branch_count: number_with_delimiter(repository.branch_count), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, empty_repo? ? nil : project_branches_path(project)) end def tags_anchor_data AnchorData.new(true, - n_('Tag (%{tag_count})', 'Tags (%{tag_count})', repository.tag_count) % { tag_count: number_with_delimiter(repository.tag_count) }, + statistic_icon('label') + + n_('%{strong_start}%{tag_count}%{strong_end} Tag', '%{strong_start}%{tag_count}%{strong_end} Tags', repository.tag_count).html_safe % { + tag_count: number_with_delimiter(repository.tag_count), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, empty_repo? ? nil : project_tags_path(project)) end def new_file_anchor_data if current_user && can_current_user_push_to_default_branch? AnchorData.new(false, - _('New file'), + statistic_icon + _('New file'), project_new_blob_path(project, default_branch || 'master'), 'success') end @@ -183,40 +204,45 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def readme_anchor_data if current_user && can_current_user_push_to_default_branch? && repository.readme.nil? AnchorData.new(false, - _('Add Readme'), + statistic_icon + _('Add README'), add_readme_path) elsif repository.readme - AnchorData.new(true, - _('Readme'), - default_view != 'readme' ? readme_path : '#readme') + AnchorData.new(false, + statistic_icon('doc-text') + _('README'), + default_view != 'readme' ? readme_path : '#readme', + 'default', + 'doc-text') end end def changelog_anchor_data if current_user && can_current_user_push_to_default_branch? && repository.changelog.blank? AnchorData.new(false, - _('Add Changelog'), + statistic_icon + _('Add CHANGELOG'), add_changelog_path) elsif repository.changelog.present? - AnchorData.new(true, - _('Changelog'), - changelog_path) + AnchorData.new(false, + statistic_icon('doc-text') + _('CHANGELOG'), + changelog_path, + 'default') end end def license_anchor_data + icon = statistic_icon('scale') + if repository.license_blob.present? AnchorData.new(true, - license_short_name, + icon + content_tag(:strong, license_short_name, class: 'project-stat-value'), license_path) else if current_user && can_current_user_push_to_default_branch? - AnchorData.new(false, - _('Add license'), + AnchorData.new(true, + content_tag(:span, icon + _('Add license'), class: 'add-license-link d-flex'), add_license_path) else - AnchorData.new(false, - _('No license. All rights reserved'), + AnchorData.new(true, + icon + content_tag(:strong, _('No license. All rights reserved'), class: 'project-stat-value'), nil) end end @@ -225,22 +251,29 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def contribution_guide_anchor_data if current_user && can_current_user_push_to_default_branch? && repository.contribution_guide.blank? AnchorData.new(false, - _('Add Contribution guide'), + statistic_icon + _('Add CONTRIBUTING'), add_contribution_guide_path) elsif repository.contribution_guide.present? - AnchorData.new(true, - _('Contribution guide'), + AnchorData.new(false, + statistic_icon('doc-text') + _('CONTRIBUTING'), contribution_guide_path) end end def autodevops_anchor_data(show_auto_devops_callout: false) if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout - AnchorData.new(auto_devops_enabled?, - auto_devops_enabled? ? _('Auto DevOps enabled') : _('Enable Auto DevOps'), - project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) + if auto_devops_enabled? + AnchorData.new(false, + statistic_icon('doc-text') + _('Auto DevOps enabled'), + project_settings_ci_cd_path(project, anchor: 'autodevops-settings'), + 'default') + else + AnchorData.new(false, + statistic_icon + _('Enable Auto DevOps'), + project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) + end elsif auto_devops_enabled? - AnchorData.new(true, + AnchorData.new(false, _('Auto DevOps enabled'), nil) end @@ -248,27 +281,32 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def kubernetes_cluster_anchor_data if current_user && can?(current_user, :create_cluster, project) - cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project) if clusters.empty? - cluster_link = new_project_cluster_path(project) - end + AnchorData.new(false, + statistic_icon + _('Add Kubernetes cluster'), + new_project_cluster_path(project)) + else + cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project) - AnchorData.new(!clusters.empty?, - clusters.empty? ? _('Add Kubernetes cluster') : _('Kubernetes configured'), - cluster_link) + AnchorData.new(false, + _('Kubernetes configured'), + cluster_link, + 'default') + end end end def gitlab_ci_anchor_data if current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled? AnchorData.new(false, - _('Set up CI/CD'), + statistic_icon + _('Set up CI/CD'), add_ci_yml_path) elsif repository.gitlab_ci_yml.present? - AnchorData.new(true, - _('CI/CD configuration'), - ci_configuration_path) + AnchorData.new(false, + statistic_icon('doc-text') + _('CI/CD configuration'), + ci_configuration_path, + 'default') end end diff --git a/app/serializers/README.md b/app/serializers/README.md index 0337f88db5f..bb94745b0b5 100644 --- a/app/serializers/README.md +++ b/app/serializers/README.md @@ -180,7 +180,7 @@ def index render json: MyResourceSerializer .new(current_user: @current_user) .represent_details(@project.resources) - nd + end end ``` @@ -196,7 +196,7 @@ def index .represent_details(@project.resources), count: @project.resources.count } - nd + end end ``` diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb new file mode 100644 index 00000000000..06a8db78476 --- /dev/null +++ b/app/serializers/diff_file_base_entity.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +class DiffFileBaseEntity < Grape::Entity + include RequestAwareEntity + include BlobHelper + include SubmoduleHelper + include DiffHelper + include TreeHelper + include ChecksCollaboration + include Gitlab::Utils::StrongMemoize + + expose :content_sha + expose :submodule?, as: :submodule + + expose :submodule_link do |diff_file| + memoized_submodule_links(diff_file).first + end + + expose :submodule_tree_url do |diff_file| + memoized_submodule_links(diff_file).last + end + + expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file| + merge_request = options[:merge_request] + + options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {} + + next unless merge_request.source_project + + project_edit_blob_path(merge_request.source_project, + tree_join(merge_request.source_branch, diff_file.new_path), + options) + end + + expose :old_path_html do |diff_file| + old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path) + old_path + end + + expose :new_path_html do |diff_file| + _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) + new_path + end + + expose :formatted_external_url, if: -> (_, options) { options[:environment] } do |diff_file| + options[:environment].formatted_external_url + end + + expose :external_url, if: -> (_, options) { options[:environment] } do |diff_file| + options[:environment].external_url_for(diff_file.new_path, diff_file.content_sha) + end + + expose :blob, using: BlobEntity + + expose :can_modify_blob do |diff_file| + merge_request = options[:merge_request] + + next unless diff_file.blob + + if merge_request&.source_project && current_user + can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch) + else + false + end + end + + expose :file_hash do |diff_file| + Digest::SHA1.hexdigest(diff_file.file_path) + end + + expose :file_path + expose :old_path + expose :new_path + expose :new_file?, as: :new_file + expose :collapsed?, as: :collapsed + expose :text?, as: :text + expose :diff_refs + expose :stored_externally?, as: :stored_externally + expose :external_storage + expose :renamed_file?, as: :renamed_file + expose :deleted_file?, as: :deleted_file + expose :mode_changed?, as: :mode_changed + expose :a_mode + expose :b_mode + + private + + def memoized_submodule_links(diff_file) + strong_memoize(:submodule_links) do + if diff_file.submodule? + submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository) + else + [] + end + end + end + + def current_user + request.current_user + end +end diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index 63ea8e8f95f..f0881829efd 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -1,63 +1,12 @@ # frozen_string_literal: true -class DiffFileEntity < Grape::Entity - include RequestAwareEntity +class DiffFileEntity < DiffFileBaseEntity include CommitsHelper - include DiffHelper - include SubmoduleHelper - include BlobHelper include IconsHelper - include TreeHelper - include ChecksCollaboration - include Gitlab::Utils::StrongMemoize - expose :submodule?, as: :submodule - - expose :submodule_link do |diff_file| - memoized_submodule_links(diff_file).first - end - - expose :submodule_tree_url do |diff_file| - memoized_submodule_links(diff_file).last - end - - expose :blob, using: BlobEntity - - expose :can_modify_blob do |diff_file| - merge_request = options[:merge_request] - - next unless diff_file.blob - - if merge_request&.source_project && current_user - can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch) - else - false - end - end - - expose :file_hash do |diff_file| - Digest::SHA1.hexdigest(diff_file.file_path) - end - - expose :file_path expose :too_large?, as: :too_large - expose :collapsed?, as: :collapsed - expose :new_file?, as: :new_file - - expose :deleted_file?, as: :deleted_file - expose :renamed_file?, as: :renamed_file - expose :old_path - expose :new_path - expose :mode_changed?, as: :mode_changed - expose :a_mode - expose :b_mode - expose :text?, as: :text expose :added_lines expose :removed_lines - expose :diff_refs - expose :content_sha - expose :stored_externally?, as: :stored_externally - expose :external_storage expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.text? && options[:merge_request] } do |diff_file| merge_request = options[:merge_request] @@ -75,36 +24,6 @@ class DiffFileEntity < Grape::Entity ) end - expose :formatted_external_url, if: -> (_, options) { options[:environment] } do |diff_file| - options[:environment].formatted_external_url - end - - expose :external_url, if: -> (_, options) { options[:environment] } do |diff_file| - options[:environment].external_url_for(diff_file.new_path, diff_file.content_sha) - end - - expose :old_path_html do |diff_file| - old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path) - old_path - end - - expose :new_path_html do |diff_file| - _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) - new_path - end - - expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file| - merge_request = options[:merge_request] - - options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {} - - next unless merge_request.source_project - - project_edit_blob_path(merge_request.source_project, - tree_join(merge_request.source_branch, diff_file.new_path), - options) - end - expose :view_path, if: -> (_, options) { options[:merge_request] } do |diff_file| merge_request = options[:merge_request] @@ -145,18 +64,4 @@ class DiffFileEntity < Grape::Entity # Used for parallel diffs expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, _) { diff_file.text? } - - def current_user - request.current_user - end - - def memoized_submodule_links(diff_file) - strong_memoize(:submodule_links) do - if diff_file.submodule? - submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository) - else - [] - end - end - end end diff --git a/app/serializers/discussion_diff_file_entity.rb b/app/serializers/discussion_diff_file_entity.rb new file mode 100644 index 00000000000..419e7edf94f --- /dev/null +++ b/app/serializers/discussion_diff_file_entity.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class DiscussionDiffFileEntity < DiffFileBaseEntity +end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index b6786a0d597..b2d9d52bd22 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -36,7 +36,7 @@ class DiscussionEntity < Grape::Entity new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) end - expose :diff_file, using: DiffFileEntity, if: -> (d, _) { d.diff_discussion? } + expose :diff_file, using: DiscussionDiffFileEntity, if: -> (d, _) { d.diff_discussion? } expose :diff_discussion?, as: :diff_discussion @@ -46,19 +46,6 @@ class DiscussionEntity < Grape::Entity expose :truncated_diff_lines, using: DiffLineEntity, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) } - expose :image_diff_html, if: -> (d, _) { d.diff_discussion? && d.on_image? } do |discussion| - diff_file = discussion.diff_file - partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff' - options[:context].render_to_string( - partial: "projects/diffs/#{partial}", - locals: { diff_file: diff_file, - position: discussion.position.to_json, - click_to_comment: false }, - layout: false, - formats: [:html] - ) - end - expose :for_commit?, as: :for_commit expose :commit_id diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb new file mode 100644 index 00000000000..58ab804a3c8 --- /dev/null +++ b/app/serializers/issue_board_entity.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class IssueBoardEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :iid + expose :title + + expose :confidential + expose :due_date + expose :project_id + expose :relative_position + + expose :project do |issue| + API::Entities::Project.represent issue.project, only: [:id, :path] + end + + expose :milestone, expose_nil: false do |issue| + API::Entities::Project.represent issue.milestone, only: [:id, :title] + end + + expose :assignees do |issue| + API::Entities::UserBasic.represent issue.assignees, only: [:id, :name, :username, :avatar_url] + end + + expose :labels do |issue| + LabelEntity.represent issue.labels, project: issue.project, only: [:id, :title, :description, :color, :priority, :text_color] + end + + expose :reference_path, if: -> (issue) { issue.project } do |issue, options| + options[:include_full_project_path] ? issue.to_reference(full: true) : issue.to_reference + end + + expose :real_path, if: -> (issue) { issue.project } do |issue| + project_issue_path(issue.project, issue) + end + + expose :issue_sidebar_endpoint, if: -> (issue) { issue.project } do |issue| + project_issue_path(issue.project, issue, format: :json, serializer: 'sidebar') + end + + expose :toggle_subscription_endpoint, if: -> (issue) { issue.project } do |issue| + toggle_subscription_project_issue_path(issue.project, issue) + end + + expose :assignable_labels_endpoint, if: -> (issue) { issue.project } do |issue| + project_labels_path(issue.project, format: :json, include_ancestor_groups: true) + end +end diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb index 37cf5e28396..d66f0a5acb7 100644 --- a/app/serializers/issue_serializer.rb +++ b/app/serializers/issue_serializer.rb @@ -4,15 +4,17 @@ class IssueSerializer < BaseSerializer # This overrided method takes care of which entity should be used # to serialize the `issue` based on `basic` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. - def represent(merge_request, opts = {}) + def represent(issue, opts = {}) entity = case opts[:serializer] when 'sidebar' IssueSidebarEntity + when 'board' + IssueBoardEntity else IssueEntity end - super(merge_request, opts, entity) + super(issue, opts, entity) end end diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb index 98743d62b50..5082245dda9 100644 --- a/app/serializers/label_entity.rb +++ b/app/serializers/label_entity.rb @@ -12,4 +12,8 @@ class LabelEntity < Grape::Entity expose :text_color expose :created_at expose :updated_at + + expose :priority, if: -> (*) { options.key?(:project) } do |label| + label.priority(options[:project]) + end end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index aef838409e0..c9669e59199 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -23,6 +23,7 @@ class PipelineEntity < Grape::Entity expose :latest?, as: :latest expose :stuck?, as: :stuck expose :auto_devops_source?, as: :auto_devops + expose :merge_request?, as: :merge_request expose :has_yaml_errors?, as: :yaml_errors expose :can_retry?, as: :retryable expose :can_cancel?, as: :cancelable @@ -48,6 +49,7 @@ class PipelineEntity < Grape::Entity expose :tag?, as: :tag expose :branch?, as: :branch + expose :merge_request?, as: :merge_request end expose :commit, using: CommitEntity diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb new file mode 100644 index 00000000000..4f1f62d145b --- /dev/null +++ b/app/serializers/projects/serverless/service_entity.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class ServiceEntity < Grape::Entity + include RequestAwareEntity + + expose :name do |service| + service.dig('metadata', 'name') + end + + expose :namespace do |service| + service.dig('metadata', 'namespace') + end + + expose :created_at do |service| + service.dig('metadata', 'creationTimestamp') + end + + expose :url do |service| + "http://#{service.dig('status', 'domain')}" + end + + expose :description do |service| + service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description') + end + + expose :image do |service| + service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name') + end + end + end +end diff --git a/app/serializers/projects/serverless/service_serializer.rb b/app/serializers/projects/serverless/service_serializer.rb new file mode 100644 index 00000000000..adfd48a8c7d --- /dev/null +++ b/app/serializers/projects/serverless/service_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class ServiceSerializer < BaseSerializer + entity Projects::Serverless::ServiceEntity + end + end +end diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb index 2a337918d21..40aa9250885 100644 --- a/app/services/access_token_validation_service.rb +++ b/app/services/access_token_validation_service.rb @@ -6,6 +6,7 @@ class AccessTokenValidationService EXPIRED = :expired REVOKED = :revoked INSUFFICIENT_SCOPE = :insufficient_scope + IMPERSONATION_DISABLED = :impersonation_disabled attr_reader :token, :request @@ -24,6 +25,11 @@ class AccessTokenValidationService elsif !self.include_any_scope?(scopes) return INSUFFICIENT_SCOPE + elsif token.respond_to?(:impersonation) && + token.impersonation && + !Gitlab.config.gitlab.impersonation_enabled + return IMPERSONATION_DISABLED + else return VALID end diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb new file mode 100644 index 00000000000..a1dd00721b5 --- /dev/null +++ b/app/services/ci/archive_trace_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Ci + class ArchiveTraceService + def execute(job) + job.trace.archive! + rescue ::Gitlab::Ci::Trace::AlreadyArchivedError + # It's already archived, thus we can safely ignore this exception. + rescue => e + # Tracks this error with application logs, Sentry, and Prometheus. + # If `archive!` keeps failing for over a week, that could incur data loss. + # (See more https://docs.gitlab.com/ee/administration/job_traces.html#new-live-trace-architecture) + # In order to avoid interrupting the system, we do not raise an exception here. + archive_error(e, job) + end + + private + + def failed_archive_counter + @failed_archive_counter ||= + Gitlab::Metrics.counter(:job_trace_archive_failed_total, + "Counter of failed attempts of trace archiving") + end + + def archive_error(error, job) + failed_archive_counter.increment + Rails.logger.error "Failed to archive trace. id: #{job.id} message: #{error.message}" + + Gitlab::Sentry + .track_exception(error, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/51502', + extra: { job_id: job.id }) + end + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 92a8438ab2f..19b5552887f 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -4,6 +4,8 @@ module Ci class CreatePipelineService < BaseService attr_reader :pipeline + CreateError = Class.new(StandardError) + SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build, Gitlab::Ci::Pipeline::Chain::Validate::Abilities, Gitlab::Ci::Pipeline::Chain::Validate::Repository, @@ -12,7 +14,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::Populate, Gitlab::Ci::Pipeline::Chain::Create].freeze - def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, &block) + def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, &block) @pipeline = Ci::Pipeline.new command = Gitlab::Ci::Pipeline::Chain::Command.new( @@ -23,6 +25,7 @@ module Ci before_sha: params[:before], trigger_request: trigger_request, schedule: schedule, + merge_request: merge_request, ignore_skip_ci: ignore_skip_ci, save_incompleted: save_on_errors, seeds_block: block, @@ -47,6 +50,14 @@ module Ci pipeline end + def execute!(*args, &block) + execute(*args, &block).tap do |pipeline| + unless pipeline.persisted? + raise CreateError, pipeline.errors.full_messages.join(',') + end + end + end + private def commit @@ -67,7 +78,7 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def auto_cancelable_pipelines - project.pipelines + project.ci_pipelines .where(ref: pipeline.ref) .where.not(id: pipeline.id) .where.not(sha: project.commit(pipeline.ref).try(:id)) diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb index 3df43657fa0..e029323774c 100644 --- a/app/services/clusters/gcp/finalize_creation_service.rb +++ b/app/services/clusters/gcp/finalize_creation_service.rb @@ -12,7 +12,8 @@ module Clusters create_gitlab_service_account! configure_kubernetes cluster.save! - configure_project_service_account + + ClusterPlatformConfigureWorker.perform_async(cluster.id) rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") @@ -25,7 +26,7 @@ module Clusters private def create_gitlab_service_account! - Clusters::Gcp::Kubernetes::CreateServiceAccountService.gitlab_creator( + Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService.gitlab_creator( kube_client, rbac: create_rbac_cluster? ).execute @@ -55,15 +56,6 @@ module Clusters ).execute end - def configure_project_service_account - kubernetes_namespace = cluster.find_or_initialize_kubernetes_namespace(cluster.cluster_project) - - Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService.new( - cluster: cluster, - kubernetes_namespace: kubernetes_namespace - ).execute - end - def authorization_type create_rbac_cluster? ? 'rbac' : 'abac' end diff --git a/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb b/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb index b31426556f6..806f320381d 100644 --- a/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb +++ b/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb @@ -27,7 +27,7 @@ module Clusters end def create_project_service_account - Clusters::Gcp::Kubernetes::CreateServiceAccountService.namespace_creator( + Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService.namespace_creator( platform.kubeclient, service_account_name: kubernetes_namespace.service_account_name, service_account_namespace: kubernetes_namespace.namespace, diff --git a/app/services/clusters/gcp/kubernetes/create_service_account_service.rb b/app/services/clusters/gcp/kubernetes/create_or_update_service_account_service.rb index dfc4bf7a358..49e766cbf13 100644 --- a/app/services/clusters/gcp/kubernetes/create_service_account_service.rb +++ b/app/services/clusters/gcp/kubernetes/create_or_update_service_account_service.rb @@ -3,7 +3,7 @@ module Clusters module Gcp module Kubernetes - class CreateServiceAccountService + class CreateOrUpdateServiceAccountService def initialize(kubeclient, service_account_name:, service_account_namespace:, token_name:, rbac:, namespace_creator: false, role_binding_name: nil) @kubeclient = kubeclient @service_account_name = service_account_name @@ -38,8 +38,9 @@ module Clusters def execute ensure_project_namespace_exists if namespace_creator - kubeclient.create_service_account(service_account_resource) - kubeclient.create_secret(service_account_token_resource) + + kubeclient.create_or_update_service_account(service_account_resource) + kubeclient.create_or_update_secret(service_account_token_resource) create_role_or_cluster_role_binding if rbac end @@ -56,9 +57,9 @@ module Clusters def create_role_or_cluster_role_binding if namespace_creator - kubeclient.create_role_binding(role_binding_resource) + kubeclient.create_or_update_role_binding(role_binding_resource) else - kubeclient.create_cluster_role_binding(cluster_role_binding_resource) + kubeclient.create_or_update_cluster_role_binding(cluster_role_binding_resource) end end diff --git a/app/services/clusters/refresh_service.rb b/app/services/clusters/refresh_service.rb new file mode 100644 index 00000000000..7c82b98a33f --- /dev/null +++ b/app/services/clusters/refresh_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Clusters + class RefreshService + def self.create_or_update_namespaces_for_cluster(cluster) + projects_with_missing_kubernetes_namespaces_for_cluster(cluster).each do |project| + create_or_update_namespace(cluster, project) + end + end + + def self.create_or_update_namespaces_for_project(project) + clusters_with_missing_kubernetes_namespaces_for_project(project).each do |cluster| + create_or_update_namespace(cluster, project) + end + end + + def self.projects_with_missing_kubernetes_namespaces_for_cluster(cluster) + cluster.all_projects.missing_kubernetes_namespace(cluster.kubernetes_namespaces) + end + + private_class_method :projects_with_missing_kubernetes_namespaces_for_cluster + + def self.clusters_with_missing_kubernetes_namespaces_for_project(project) + project.all_clusters.missing_kubernetes_namespace(project.kubernetes_namespaces) + end + + private_class_method :clusters_with_missing_kubernetes_namespaces_for_project + + def self.create_or_update_namespace(cluster, project) + kubernetes_namespace = cluster.find_or_initialize_kubernetes_namespace_for_project(project) + + ::Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService.new( + cluster: cluster, + kubernetes_namespace: kubernetes_namespace + ).execute + end + + private_class_method :create_or_update_namespace + end +end diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index c9d3ee31d82..927634c2159 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -8,6 +8,7 @@ module Files transformer = Lfs::FileTransformer.new(project, @branch_name) actions = actions_after_lfs_transformation(transformer, params[:actions]) + actions = transform_move_actions(actions) commit_actions!(actions) end @@ -26,6 +27,16 @@ module Files end end + # When moving a file, `content: nil` means "use the contents of the previous + # file", while `content: ''` means "move the file and set it to empty" + def transform_move_actions(actions) + actions.map do |action| + action[:infer_content] = true if action[:content].nil? + + action + end + end + def commit_actions!(actions) repository.multi_action( current_user, diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 28c3219b37b..fe19abf50f6 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -54,6 +54,24 @@ module MergeRequests merge_request, merge_request.project, current_user, merge_request.assignee) end + def create_merge_request_pipeline(merge_request, user) + return unless Feature.enabled?(:ci_merge_request_pipeline, + merge_request.source_project, + default_enabled: true) + + ## + # UpdateMergeRequestsWorker could be retried by an exception. + # MR pipelines should not be recreated in such case. + return if merge_request.merge_request_pipeline_exists? + + Ci::CreatePipelineService + .new(merge_request.source_project, user, ref: merge_request.source_branch) + .execute(:merge_request, + ignore_skip_ci: true, + save_on_errors: false, + merge_request: merge_request) + end + # Returns all origin and fork merge requests from `@project` satisfying passed arguments. # rubocop: disable CodeReuse/ActiveRecord def merge_requests_for(source_branch, mr_states: [:opened]) diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 6081a7d1de0..7bb9fa60515 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -25,6 +25,7 @@ module MergeRequests def after_create(issuable) todo_service.new_merge_request(issuable, current_user) issuable.cache_merge_request_closes_issues!(current_user) + create_merge_request_pipeline(issuable, current_user) update_merge_requests_head_pipeline(issuable) super @@ -49,18 +50,14 @@ module MergeRequests merge_request.update(head_pipeline_id: pipeline.id) if pipeline end - # rubocop: disable CodeReuse/ActiveRecord def head_pipeline_for(merge_request) return unless merge_request.source_project sha = merge_request.source_branch_sha return unless sha - pipelines = merge_request.source_project.pipelines.where(ref: merge_request.source_branch, sha: sha) - - pipelines.order(id: :desc).first + merge_request.all_pipelines(shas: sha).first end - # rubocop: enable CodeReuse/ActiveRecord def set_projects! # @project is used to determine whether the user can set the merge request's diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 5fe48da1cd6..f712b8863cd 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -58,13 +58,27 @@ module MergeRequests .preload(:latest_merge_request_diff) .where(target_branch: @push.branch_name).to_a .select(&:diff_head_commit) + .select do |merge_request| + commit_ids.include?(merge_request.diff_head_sha) && + merge_request.merge_request_diff.state != 'empty' + end + merge_requests = filter_merge_requests(merge_requests) + + return if merge_requests.empty? - merge_requests = merge_requests.select do |merge_request| - commit_ids.include?(merge_request.diff_head_sha) && - merge_request.merge_request_diff.state != 'empty' + commit_analyze_enabled = Feature.enabled?(:branch_push_merge_commit_analyze, @project, default_enabled: true) + if commit_analyze_enabled + analyzer = Gitlab::BranchPushMergeCommitAnalyzer.new( + @commits.reverse, + relevant_commit_ids: merge_requests.map(&:diff_head_sha) + ) end - filter_merge_requests(merge_requests).each do |merge_request| + merge_requests.each do |merge_request| + if commit_analyze_enabled + merge_request.merge_commit_sha = analyzer.get_merge_commit(merge_request.diff_head_sha) + end + MergeRequests::PostMergeService .new(merge_request.target_project, @current_user) .execute(merge_request) @@ -92,6 +106,7 @@ module MergeRequests end merge_request.mark_as_unchecked + create_merge_request_pipeline(merge_request, current_user) UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 5904bfbf88d..e24ef7f9c87 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -466,6 +466,14 @@ class NotificationService end end + def repository_cleanup_success(project, user) + mailer.send(:repository_cleanup_success_email, project, user).deliver_later + end + + def repository_cleanup_failure(project, user, error) + mailer.send(:repository_cleanup_failure_email, project, user, error).deliver_later + end + protected def new_resource_email(target, method) diff --git a/app/services/projects/auto_devops/disable_service.rb b/app/services/projects/auto_devops/disable_service.rb index 1b578a3c5ce..6608b3da1a8 100644 --- a/app/services/projects/auto_devops/disable_service.rb +++ b/app/services/projects/auto_devops/disable_service.rb @@ -34,7 +34,7 @@ module Projects end def auto_devops_pipelines - @auto_devops_pipelines ||= project.pipelines.auto_devops_source + @auto_devops_pipelines ||= project.ci_pipelines.auto_devops_source end end end diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb new file mode 100644 index 00000000000..12103ea34b5 --- /dev/null +++ b/app/services/projects/cleanup_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Projects + # The CleanupService removes data from the project repository following a + # BFG rewrite: https://rtyley.github.io/bfg-repo-cleaner/ + # + # Before executing this service, all refs rewritten by BFG should have been + # pushed to the repository + class CleanupService < BaseService + NoUploadError = StandardError.new("Couldn't find uploaded object map") + + include Gitlab::Utils::StrongMemoize + + # Attempt to clean up the project following the push. Warning: this is + # destructive! + # + # path is the path of an upload of a BFG object map file. It contains a line + # per rewritten object, with the old and new SHAs space-separated. It can be + # used to update or remove content that references the objects that BFG has + # altered + # + # Currently, only the project repository is modified by this service, but we + # may wish to modify other data sources in the future. + def execute + apply_bfg_object_map! + + # Remove older objects that are no longer referenced + GitGarbageCollectWorker.new.perform(project.id, :gc) + + # The cache may now be inaccurate, and holding onto it could prevent + # bugs assuming the presence of some object from manifesting for some + # time. Better to feel the pain immediately. + project.repository.expire_all_method_caches + + project.bfg_object_map.remove! + end + + private + + def apply_bfg_object_map! + raise NoUploadError unless project.bfg_object_map.exists? + + project.bfg_object_map.open do |io| + repository_cleaner.apply_bfg_object_map(io) + end + end + + def repository_cleaner + @repository_cleaner ||= Gitlab::Git::RepositoryCleaner.new(repository.raw) + end + end +end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 20bfe5af7a1..d03137b63b2 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -9,7 +9,7 @@ module Projects end def execute - if @params[:template_name]&.present? + if @params[:template_name].present? return ::Projects::CreateFromTemplateService.new(current_user, params).execute end @@ -86,6 +86,8 @@ module Projects @project.create_wiki unless skip_wiki? end + @project.track_project_repository + event_service.create_project(@project, current_user) system_hook_service.execute_hooks_for(@project, :create) @@ -94,6 +96,8 @@ module Projects current_user.invalidate_personal_projects_count create_readme if @initialize_with_readme + + configure_group_clusters_for_project end # Refresh the current user's authorizations inline (so they can access the @@ -119,6 +123,10 @@ module Projects Files::CreateService.new(@project, current_user, commit_attrs).execute end + def configure_group_clusters_for_project + ClusterProjectConfigureWorker.perform_async(@project.id) + end + def skip_wiki? !@project.feature_available?(:wiki, current_user) || @skip_wiki end @@ -148,7 +156,7 @@ module Projects Rails.logger.error(log_message) if @project - @project.mark_import_as_failed(message) if @project.persisted? && @project.import? + @project.import_state.mark_as_failed(message) if @project.persisted? && @project.import? end @project @@ -181,7 +189,7 @@ module Projects def import_schedule if @project.errors.empty? - @project.import_schedule if @project.import? && !@project.bare_repository_import? + @project.import_state.schedule if @project.import? && !@project.bare_repository_import? else fail(error: @project.errors.full_messages.join(', ')) end diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb index 4462d504071..f3e026ba38c 100644 --- a/app/services/projects/hashed_storage/migrate_repository_service.rb +++ b/app/services/projects/hashed_storage/migrate_repository_service.rb @@ -30,6 +30,7 @@ module Projects if result project.write_repository_config + project.track_project_repository else rollback_folder_move project.storage_version = nil diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 9d40ab166ff..9db3fd9cf17 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -54,6 +54,7 @@ module Projects end attempt_transfer_transaction + configure_group_clusters_for_project end # rubocop: enable CodeReuse/ActiveRecord @@ -162,5 +163,9 @@ module Projects @new_namespace.full_path ) end + + def configure_group_clusters_for_project + ClusterProjectConfigureWorker.perform_async(project.id) + end end end diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb index 45e0e61e5c4..7e14ddcd017 100644 --- a/app/services/test_hooks/project_service.rb +++ b/app/services/test_hooks/project_service.rb @@ -49,7 +49,7 @@ module TestHooks end def pipeline_events_data - pipeline = project.pipelines.first + pipeline = project.ci_pipelines.first throw(:validation_error, 'Ensure the project has CI pipelines.') unless pipeline.present? Gitlab::DataBuilder::Pipeline.build(pipeline) diff --git a/app/validators/duration_validator.rb b/app/validators/duration_validator.rb index 811828169ca..defd28d7d3b 100644 --- a/app/validators/duration_validator.rb +++ b/app/validators/duration_validator.rb @@ -14,6 +14,10 @@ class DurationValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) ChronicDuration.parse(value) rescue ChronicDuration::DurationParseError - record.errors.add(attribute, "is not a correct duration") + if options[:message] + record.errors.add(:base, options[:message]) + else + record.errors.add(attribute, "is not a correct duration") + end end end diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml index b201e6bf10e..19c2a50ebd9 100644 --- a/app/views/admin/runners/_sort_dropdown.html.haml +++ b/app/views/admin/runners/_sort_dropdown.html.haml @@ -1,7 +1,7 @@ - sorted_by = sort_options_hash[@sort] .dropdown.inline.prepend-left-10 - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } = sorted_by = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index bfbc16d37a0..a733f420d11 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -8,7 +8,7 @@ %span.cred (Admin) .float-right - - if @user != current_user && @user.can?(:log_in) + - if impersonation_enabled? && @user != current_user && @user.can?(:log_in) = link_to 'Impersonate', impersonate_admin_user_path(@user), method: :post, class: "btn btn-nr btn-grouped btn-info" = link_to edit_admin_user_path(@user), class: "btn btn-nr btn-grouped" do %i.fa.fa-pencil-square-o diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index f910e90d6ca..600120c4f05 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -9,28 +9,20 @@ .search-holder .search-field-holder = search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false + - if @sort.present? + = hidden_field_tag :sort, @sort = icon("search", class: "search-icon") - .dropdown - - toggle_text = if @sort.present? then sort_options_hash[@sort] else sort_title_name end + = button_tag 'Search users' if Rails.env.test? + .dropdown.user-sort-dropdown + - toggle_text = if @sort.present? then users_sort_options_hash[@sort] else sort_title_name end = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) %ul.dropdown-menu.dropdown-menu-right %li.dropdown-header Sort by %li - = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do - = sort_title_name - = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do - = sort_title_recently_signin - = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do - = sort_title_oldest_signin - = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do - = sort_title_recently_created - = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do - = sort_title_oldest_created - = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do - = sort_title_recently_updated - = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do - = sort_title_oldest_updated + - users_sort_options_hash.each do |value, title| + = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do + = title = link_to 'New user', new_admin_user_path, class: 'btn btn-success btn-search' .top-area.scrolling-tabs-container.inner-page-scroll-tabs diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 85d1002243b..73b11d509d3 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,6 +1,6 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } - %button.close.js-close{ type: "button" } × +.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert' } + %button.close{ type: "button", data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } × .gcp-signup-offer--content .gcp-signup-offer--icon.append-right-8 = sprite_icon("information", size: 16) diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml index 4dbda5c754b..31d4b3da4f1 100644 --- a/app/views/dashboard/activity.html.haml +++ b/app/views/dashboard/activity.html.haml @@ -4,9 +4,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") - -= render_if_exists "shared/gold_trial_callout" - - page_title "Activity" - header_title "Activity", activity_dashboard_path diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 2f7add600e4..50f39f93283 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -1,8 +1,6 @@ - @hide_top_links = true - page_title "Groups" - header_title "Groups", dashboard_groups_path - -= render_if_exists "shared/gold_trial_callout" = render 'dashboard/groups_head' - if params[:filter].blank? && @groups.empty? diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index afd46412fab..fdd5c19d562 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -4,8 +4,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues") -= render_if_exists "shared/gold_trial_callout" - .page-title-holder %h1.page-title= _('Issues') diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 3e5f13b92e3..77cfa1271df 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -2,8 +2,6 @@ - page_title _("Merge Requests") - @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username) -= render_if_exists "shared/gold_trial_callout" - .page-title-holder %h1.page-title= _('Merge Requests') diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 446b4715b2d..deed774a4a5 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -4,8 +4,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") -= render_if_exists "shared/gold_trial_callout" - - page_title "Projects" - header_title "Projects", dashboard_projects_path diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index ad08409c8fe..8933d9e31ff 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -4,8 +4,6 @@ - page_title "Starred Projects" - header_title "Projects", dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - %div{ class: container_class } = render "projects/last_push" = render 'dashboard/projects_head' diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 47729321961..d2593179f17 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -2,8 +2,6 @@ - page_title "Todos" - header_title "Todos", dashboard_todos_path -= render_if_exists "shared/gold_trial_callout" - .page-title-holder %h1.page-title= _('Todos') diff --git a/app/views/devise/mailer/email_changed.html.haml b/app/views/devise/mailer/email_changed.html.haml new file mode 100644 index 00000000000..5398430fdfd --- /dev/null +++ b/app/views/devise/mailer/email_changed.html.haml @@ -0,0 +1,12 @@ += email_default_heading("Hello, #{@resource.name}!") + +- if @resource.try(:unconfirmed_email?) + %p + We're contacting you to notify you that your email is being changed to #{@resource.reload.unconfirmed_email}. +- else + %p + We're contacting you to notify you that your email has been changed to #{@resource.email}. + +%p + If you did not initiate this change, please contact your administrator + immediately. diff --git a/app/views/devise/mailer/email_changed.text.erb b/app/views/devise/mailer/email_changed.text.erb new file mode 100644 index 00000000000..18137389e7b --- /dev/null +++ b/app/views/devise/mailer/email_changed.text.erb @@ -0,0 +1,10 @@ +Hello, <%= @resource.name %>! + +<% if @resource.try(:unconfirmed_email?) %> +We're contacting you to notify you that your email is being changed to <%= @resource.reload.unconfirmed_email %>. +<% else %> +We're contacting you to notify you that your email has been changed to <%= @resource.email %>. +<% end %> + +If you did not initiate this change, please contact your administrator +immediately. diff --git a/app/views/errors/access_denied.html.haml b/app/views/errors/access_denied.html.haml index 8ae29b9d337..46931b5932d 100644 --- a/app/views/errors/access_denied.html.haml +++ b/app/views/errors/access_denied.html.haml @@ -9,7 +9,7 @@ %p = message %p - = s_('403|Please contact your GitLab administrator to get the permission.') + = s_('403|Please contact your GitLab administrator to get permission.') .action-container.js-go-back{ style: 'display: none' } %a{ href: 'javascript:history.back()', class: 'btn btn-success' } = s_('Go Back') diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index 869be4e8581..a3eafc61d0a 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -2,8 +2,6 @@ - page_title _("Groups") - header_title _("Groups"), dashboard_groups_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/groups_head' - else diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index b694103ccaf..f518205f14c 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -1,8 +1,8 @@ - if current_user .dropdown - %button.dropdown-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' } - = icon('globe') - %span.light= _("Visibility:") + %button.dropdown-menu-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' } + = icon('globe', class: 'mt-1') + %span.light.ml-3= _("Visibility:") - if params[:visibility_level].present? = visibility_level_label(params[:visibility_level].to_i) - else diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml index d18dec7bd8e..452f390695c 100644 --- a/app/views/explore/projects/index.html.haml +++ b/app/views/explore/projects/index.html.haml @@ -2,8 +2,6 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml index d18dec7bd8e..452f390695c 100644 --- a/app/views/explore/projects/starred.html.haml +++ b/app/views/explore/projects/starred.html.haml @@ -2,8 +2,6 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml index d18dec7bd8e..452f390695c 100644 --- a/app/views/explore/projects/trending.html.haml +++ b/app/views/explore/projects/trending.html.haml @@ -2,8 +2,6 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index a0760c2073b..6219da2c715 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -1,4 +1,4 @@ -.group-home-panel.text-center +.group-home-panel.text-center.border-bottom %div{ class: container_class } .avatar-container.s70.group-avatar = group_icon(@group, class: "avatar s70 avatar-tile") diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 869c54d89ea..39d0f620283 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -37,6 +37,7 @@ .settings-content = render 'shared/badges/badge_settings' += render_if_exists 'groups/custom_project_templates_setting' = render_if_exists 'groups/templates_setting', expanded: expanded %section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) } diff --git a/app/views/groups/labels/edit.html.haml b/app/views/groups/labels/edit.html.haml index 836981fc6fd..586b0f6ebfa 100644 --- a/app/views/groups/labels/edit.html.haml +++ b/app/views/groups/labels/edit.html.haml @@ -1,4 +1,6 @@ -- page_title 'Edit', @label.name, 'Labels' +- add_to_breadcrumbs _("Labels"), group_labels_path(@group) +- breadcrumb_title _("Edit") +- page_title "Edit", @label.name, _("Labels") %h3.page-title Edit Label diff --git a/app/views/groups/labels/new.html.haml b/app/views/groups/labels/new.html.haml index 538c353cf2d..bb0b8d2b94d 100644 --- a/app/views/groups/labels/new.html.haml +++ b/app/views/groups/labels/new.html.haml @@ -1,5 +1,6 @@ -- breadcrumb_title "Labels" -- page_title 'New Label' +- add_to_breadcrumbs _("Labels"), group_labels_path(@group) +- breadcrumb_title _("New") +- page_title _("New Label") %h3.page-title New Label diff --git a/app/views/groups/milestones/edit.html.haml b/app/views/groups/milestones/edit.html.haml index 5f6d7d209d0..c703d5f7f93 100644 --- a/app/views/groups/milestones/edit.html.haml +++ b/app/views/groups/milestones/edit.html.haml @@ -1,7 +1,10 @@ -- page_title "Milestones" +- breadcrumb_title _("Edit") +- page_title _("Milestones") + - render "header_title" %h3.page-title Edit Milestone +%hr = render "form" diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml index d758e314d41..248cb3b0ba5 100644 --- a/app/views/groups/milestones/new.html.haml +++ b/app/views/groups/milestones/new.html.haml @@ -1,7 +1,12 @@ -- breadcrumb_title "Milestones" -- page_title "Milestones" +- @no_container = true +- add_to_breadcrumbs _("Milestones"), group_milestones_path(@group) +- breadcrumb_title _("New") +- page_title _("Milestones"), @milestone.name, _("Milestones") -%h3.page-title - New Milestone +%div{ class: container_class } + %h3.page-title + New Milestone -= render "form" + %hr + + = render "form" diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml new file mode 100644 index 00000000000..b24d6e27536 --- /dev/null +++ b/app/views/ide/_show.html.haml @@ -0,0 +1,10 @@ +- @body_class = 'ide-layout' +- page_title 'IDE' + +- content_for :page_specific_javascripts do + = stylesheet_link_tag 'page_bundles/ide' + +#ide.ide-loading{ data: ide_data() } + .text-center + = icon('spinner spin 2x') + %h2.clgray= _('Loading the GitLab IDE...') diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml index d8bd37fe986..0323f9d093d 100644 --- a/app/views/ide/index.html.haml +++ b/app/views/ide/index.html.haml @@ -1,17 +1 @@ -- @body_class = 'ide-layout' -- page_title 'IDE' - -- content_for :page_specific_javascripts do - = stylesheet_link_tag 'page_bundles/ide' - -#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), - "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), - "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'), - "pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'), - "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'), - "ci-help-page-path" => help_page_path('ci/quick_start/README'), - "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'), - "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s } } - .text-center - = icon('spinner spin 2x') - %h2.clgray= _('Loading the GitLab IDE...') += render 'ide/show' diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index 3b1b5e55302..2336e1e83f9 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -37,11 +37,12 @@ %td = link_to project.full_path, [project.namespace.becomes(Namespace), project] %td.job-status - - if project.import_status == 'finished' + - case project.import_status + - when 'finished' %span %i.fa.fa-check = _('done') - - elsif project.import_status == 'started' + - when 'started' %i.fa.fa-spinner.fa-spin = _('started') - else diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index 56d4f2ba881..ef69197e453 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -38,9 +38,10 @@ %td = link_to project.full_path, [project.namespace.becomes(Namespace), project] %td.job-status - - if project.import_status == 'finished' + - case project.import_status + - when 'finished' = icon('check', text: 'Done') - - elsif project.import_status == 'started' + - when 'started' = icon('spin', text: 'started') - else = project.human_import_status_name diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml index 830d141ebea..eca67582d6f 100644 --- a/app/views/import/fogbugz/status.html.haml +++ b/app/views/import/fogbugz/status.html.haml @@ -34,11 +34,12 @@ %td = link_to project.full_path, [project.namespace.becomes(Namespace), project] %td.job-status - - if project.import_status == 'finished' + - case project.import_status + - when 'finished' %span %i.fa.fa-check = _("done") - - elsif project.import_status == 'started' + - when 'started' %i.fa.fa-spinner.fa-spin = _("started") - else diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml index b7bfbae5edf..a5fa12fe7df 100644 --- a/app/views/import/gitlab/status.html.haml +++ b/app/views/import/gitlab/status.html.haml @@ -30,11 +30,12 @@ %td = link_to project.full_path, [project.namespace.becomes(Namespace), project] %td.job-status - - if project.import_status == 'finished' + - case project.import_status + - when 'finished' %span %i.fa.fa-check = _('done') - - elsif project.import_status == 'started' + - when 'started' %i.fa.fa-spinner.fa-spin = _('started') - else diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml index 347e2820f94..f322b7a956a 100644 --- a/app/views/import/google_code/status.html.haml +++ b/app/views/import/google_code/status.html.haml @@ -39,11 +39,12 @@ %td = link_to project.full_path, [project.namespace.becomes(Namespace), project] %td.job-status - - if project.import_status == 'finished' + - case project.import_status + - when 'finished' %span %i.fa.fa-check = _("done") - - elsif project.import_status == 'started' + - when 'started' %i.fa.fa-spinner.fa-spin = _("started") - else diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml index c2bb1216c5f..30ab5781014 100644 --- a/app/views/invites/show.html.haml +++ b/app/views/invites/show.html.haml @@ -1,5 +1,5 @@ -- page_title "Invitation" -%h3.page-title Invitation +- page_title _("Invitation") +%h3.page-title= _("Invitation") %p You have been invited @@ -24,14 +24,17 @@ - if is_member %p - However, you are already a member of this #{@member.source.is_a?(Group) ? "group" : "project"}. - Sign in using a different account to accept the invitation. + - member_source = @member.source.is_a?(Group) ? _("group") : _("project") + = _("However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation.") % { member_source: member_source } - if @member.invite_email != current_user.email %p - Note that this invitation was sent to #{mail_to @member.invite_email}, but you are signed in as #{link_to current_user.to_reference, user_url(current_user)} with email #{mail_to current_user.email}. + - mail_to_invite_email = mail_to(@member.invite_email) + - mail_to_current_user = mail_to(current_user.email) + - link_to_current_user = link_to(current_user.to_reference, user_url(current_user)) + = _("Note that this invitation was sent to %{mail_to_invite_email}, but you are signed in as %{link_to_current_user} with email %{mail_to_current_user}.").html_safe % { mail_to_invite_email: mail_to_invite_email, mail_to_current_user: mail_to_current_user, link_to_current_user: link_to_current_user } - unless is_member .actions - = link_to "Accept invitation", accept_invite_url(@token), method: :post, class: "btn btn-success" - = link_to "Decline", decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10" + = link_to _("Accept invitation"), accept_invite_url(@token), method: :post, class: "btn btn-success" + = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10" diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index ab15889a465..b89541a3c9f 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -222,6 +222,12 @@ %span = _('Environments') + - if project_nav_tab? :serverless + = nav_link(controller: :functions) do + = link_to project_serverless_functions_path(@project), title: _('Serverless') do + %span + = _('Serverless') + - if project_nav_tab? :clusters - show_cluster_hint = show_gke_cluster_integration_callout?(@project) = nav_link(controller: [:clusters, :user, :gcp]) do diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml index 94bd6f96dbc..1fbae2f64ed 100644 --- a/app/views/notify/_note_email.html.haml +++ b/app/views/notify/_note_email.html.haml @@ -1,13 +1,18 @@ -- discussion = @note.discussion if @note.part_of_discussion? +- note = local_assigns.fetch(:note, @note) +- diff_limit = local_assigns.fetch(:diff_limit, nil) +- target_url = local_assigns.fetch(:target_url, @target_url) +- note_style = local_assigns.fetch(:note_style, "") + +- discussion = note.discussion if note.part_of_discussion? - diff_discussion = discussion&.diff_discussion? - on_image = discussion.on_image? if diff_discussion - if discussion - phrase_end_char = on_image ? "." : ":" - %p.details + %p{ style: "color: #777777;" } = succeed phrase_end_char do - = link_to @note.author_name, user_url(@note.author) + = link_to note.author_name, user_url(note.author) - if diff_discussion - if discussion.new_discussion? @@ -15,16 +20,16 @@ - else commented on a discussion - on #{link_to discussion.file_path, @target_url} + on #{link_to discussion.file_path, target_url} - else - if discussion.new_discussion? started a new discussion - else - commented on a #{link_to 'discussion', @target_url} + commented on a #{link_to 'discussion', target_url} - elsif Gitlab::CurrentSettings.email_author_in_body %p.details - #{link_to @note.author_name, user_url(@note.author)} commented: + #{link_to note.author_name, user_url(note.author)} commented: - if diff_discussion && !on_image = content_for :head do @@ -32,11 +37,11 @@ %table = render partial: "projects/diffs/line", - collection: discussion.truncated_diff_lines, + collection: discussion.truncated_diff_lines(diff_limit: diff_limit), as: :line, locals: { diff_file: discussion.diff_file, plain: true, email: true } -%div - = markdown(@note.note, pipeline: :email, author: @note.author) +%div{ style: note_style } + = markdown(note.note, pipeline: :email, author: note.author) diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb index c319cb55e87..4bf252b6ce1 100644 --- a/app/views/notify/_note_email.text.erb +++ b/app/views/notify/_note_email.text.erb @@ -1,6 +1,9 @@ -<% discussion = @note.discussion if @note.part_of_discussion? -%> +<% note = local_assigns.fetch(:note, @note) -%> +<% diff_limit = local_assigns.fetch(:diff_limit, nil) -%> + +<% discussion = note.discussion if note.part_of_discussion? -%> <% if discussion && !discussion.individual_note? -%> -<%= @note.author_name -%> +<%= note.author_name -%> <% if discussion.new_discussion? -%> <%= " started a new discussion" -%> <% else -%> @@ -13,14 +16,14 @@ <% elsif Gitlab::CurrentSettings.email_author_in_body -%> -<%= "#{@note.author_name} commented:" -%> +<%= "#{note.author_name} commented:" -%> <% end -%> <% if discussion&.diff_discussion? -%> -<% discussion.truncated_diff_lines(highlight: false).each do |line| -%> +<% discussion.truncated_diff_lines(highlight: false, diff_limit: diff_limit).each do |line| -%> <%= "> #{line.text}\n" -%> <% end -%> <% end -%> -<%= @note.note -%> +<%= note.note -%> diff --git a/app/views/notify/note_snippet_email.html.haml b/app/views/notify/note_project_snippet_email.html.haml index 5e69f01a486..5e69f01a486 100644 --- a/app/views/notify/note_snippet_email.html.haml +++ b/app/views/notify/note_project_snippet_email.html.haml diff --git a/app/views/notify/note_snippet_email.text.erb b/app/views/notify/note_project_snippet_email.text.erb index 413d9e6e9ac..413d9e6e9ac 100644 --- a/app/views/notify/note_snippet_email.text.erb +++ b/app/views/notify/note_project_snippet_email.text.erb diff --git a/app/views/notify/repository_cleanup_failure_email.text.erb b/app/views/notify/repository_cleanup_failure_email.text.erb new file mode 100644 index 00000000000..f5a426a51d1 --- /dev/null +++ b/app/views/notify/repository_cleanup_failure_email.text.erb @@ -0,0 +1,3 @@ +Repository cleanup failed on <%= @project.web_url %> + +<%= @error %> diff --git a/app/views/notify/repository_cleanup_success_email.text.erb b/app/views/notify/repository_cleanup_success_email.text.erb new file mode 100644 index 00000000000..e6e95da2fcc --- /dev/null +++ b/app/views/notify/repository_cleanup_success_email.text.erb @@ -0,0 +1,3 @@ +Repository cleanup succeeded on <%= @project.web_url %> + +Repository size is now <%= "%.1f" % (@project.repository.size || 0) %> MiB diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 2220b4eee96..e167e094240 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -21,7 +21,7 @@ = link_to 'Enable two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-success' %hr -- if button_based_providers.any? +- if display_providers_on_profile? .row.prepend-top-default .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 @@ -46,6 +46,7 @@ - else = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do Connect + = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: local_assigns[:group_saml_identities] %hr - if current_user.can_change_username? .row.prepend-top-default @@ -66,7 +67,7 @@ %h4.prepend-top-0.danger-title = s_('Profiles|Delete account') .col-lg-8 - - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) + - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user) %p = s_('Profiles|Deleting an account has the following effects:') = render 'users/deletion_guidance', user: current_user @@ -79,10 +80,10 @@ confirm_with_password: ('true' if current_user.confirm_deletion_with_password?), username: current_user.username } } - else - - if @user.solo_owned_groups.present? + - if current_user.solo_owned_groups.present? %p = s_('Profiles|Your account is currently an owner in these groups:') - %strong= @user.solo_owned_groups.map(&:name).join(', ') + %strong= current_user.solo_owned_groups.map(&:name).join(', ') %p = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.') - else diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 79530e78154..22a721ee9ad 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -1,7 +1,9 @@ +- is_project_overview = local_assigns.fetch(:is_project_overview, false) - commit = local_assigns.fetch(:commit) { @repository.commit } - ref = local_assigns.fetch(:ref) { current_ref } - project = local_assigns.fetch(:project) { @project } - content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) } +- show_auto_devops_callout = show_auto_devops_callout?(@project) #tree-holder.tree-holder.clearfix .nav-block @@ -10,4 +12,8 @@ - if commit = render 'shared/commit_well', commit: commit, ref: ref, project: project + - if is_project_overview + .project-buttons.append-bottom-default + = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout) + = render 'projects/tree/tree_content', tree: @tree, content_url: content_url diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index dcef4dd5b69..e191b009db2 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,83 +1,75 @@ - empty_repo = @project.empty_repo? -- license = @project.license_anchor_data +- show_auto_devops_callout = show_auto_devops_callout?(@project) .project-home-panel{ class: ("empty-project" if empty_repo) } - .limit-container-width{ class: container_class } - .project-header.d-flex.flex-row.flex-wrap.align-items-center.append-bottom-8 - .project-title-row.d-flex.align-items-center - .avatar-container.project-avatar.float-none - = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s24', width: 24, height: 24) - %h1.project-title.d-flex.align-items-baseline.qa-project-name - = @project.name - .project-metadata.d-flex.flex-row.flex-wrap.align-items-baseline - .project-visibility.d-inline-flex.align-items-baseline.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } - = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) - = visibility_level_label(@project.visibility_level) - - if license.present? - .project-license.d-inline-flex.align-items-baseline - = link_to_if license.link, sprite_icon('scale', size: 16, css_class: 'icon') + license.label, license.link, class: license.enabled ? 'btn btn-link btn-secondary-hover-link' : 'btn btn-link' - - if @project.tag_list.present? - .project-tag-list.d-inline-flex.align-items-baseline.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_tags? ? @project.tag_list.join(', ') : nil } - = sprite_icon('tag', size: 16, css_class: 'icon') - = @project.tags_to_show - - if @project.has_extra_tags? - = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown } + .project-header.row.append-bottom-8 + .project-title-row.col-md-12.col-lg-7.d-flex + .avatar-container.project-avatar.float-none + = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64) + .d-flex.flex-column.flex-wrap.align-items-baseline + .d-inline-flex.align-items-baseline + %h1.project-title.qa-project-name + = @project.name + %span.project-visibility.prepend-left-8.d-inline-flex.align-items-baseline.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } + = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) + .project-metadata.d-flex.align-items-center + - if can?(current_user, :read_project, @project) + %span.text-secondary + = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } + - if current_user + %span.access-request-links.prepend-left-8 + = render 'shared/members/access_request_links', source: @project + - if @project.tag_list.present? + %span.project-tag-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_tags? ? @project.tag_list.join(', ') : nil } + = sprite_icon('tag', size: 16, css_class: 'icon append-right-4') + = @project.tags_to_show + - if @project.has_extra_tags? + = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown } - .project-home-desc - - if @project.description.present? - .project-description - .project-description-markdown.read-more-container - = markdown_field(@project, :description) - %button.btn.btn-blank.btn-link.text-secondary.js-read-more-trigger.text-secondary.d-lg-none{ type: "button" } - = _("Read more") - - - if can?(current_user, :read_project, @project) - .text-secondary.prepend-top-8 - = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } - - - if @project.forked? - %p - - if @project.fork_source - #{ s_('ForkedFromProjectPath|Forked from') } - = link_to project_path(@project.fork_source) do - = fork_source_name(@project) - - else - - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') - = deleted_message % { project_name: fork_source_name(@project) } - - - if @project.badges.present? - .project-badges.prepend-top-default.append-bottom-default - - @project.badges.each do |badge| - %a.append-right-8{ href: badge.rendered_link_url(@project), - target: '_blank', - rel: 'noopener noreferrer' }> - %img.project-badge{ src: badge.rendered_image_url(@project), - 'aria-hidden': true, - alt: 'Project badge' }> + .project-repo-buttons.col-md-12.col-lg-5.d-inline-flex.flex-wrap.justify-content-lg-end + - if current_user + .d-inline-flex + = render 'projects/buttons/notifications', notification_setting: @notification_setting, btn_class: 'btn-xs' - .project-repo-buttons.d-inline-flex.flex-wrap .count-buttons.d-inline-flex = render 'projects/buttons/star' = render 'projects/buttons/fork' - if can?(current_user, :download_code, @project) - .project-clone-holder.d-inline-flex.d-sm-none + .project-clone-holder.d-inline-flex.d-md-none.btn-block = render "shared/mobile_clone_panel" - .project-clone-holder.d-none.d-sm-inline-flex - = render "shared/clone_panel" + .project-clone-holder.d-none.d-md-inline-flex + = render "projects/buttons/clone" - - if show_xcode_link?(@project) - .project-action-button.project-xcode.inline - = render "projects/buttons/xcode_link" + - if can?(current_user, :download_code, @project) + %nav.project-stats + .nav-links.quick-links.mt-3 + = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) - - if current_user - - if can?(current_user, :download_code, @project) - .d-none.d-sm-inline-flex - = render 'projects/buttons/download', project: @project, ref: @ref - .d-none.d-sm-inline-flex - = render 'projects/buttons/dropdown' + .project-home-desc.mt-1 + - if @project.description.present? + .project-description + .project-description-markdown.read-more-container + = markdown_field(@project, :description) + %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } + = _("Read more") + + - if @project.forked? + %p + - if @project.fork_source + #{ s_('ForkedFromProjectPath|Forked from') } + = link_to project_path(@project.fork_source) do + = fork_source_name(@project) + - else + - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') + = deleted_message % { project_name: fork_source_name(@project) } - .d-none.d-sm-inline-flex - = render 'shared/notifications/button', notification_setting: @notification_setting - .d-none.d-sm-inline-flex - = render 'shared/members/access_request_buttons', source: @project + - if @project.badges.present? + .project-badges.mb-2 + - @project.badges.each do |badge| + %a.append-right-8{ href: badge.rendered_link_url(@project), + target: '_blank', + rel: 'noopener noreferrer' }> + %img.project-badge{ src: badge.rendered_image_url(@project), + 'aria-hidden': true, + alt: 'Project badge' }> diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml index 4cf49f3cf62..8e3d759b683 100644 --- a/app/views/projects/_stat_anchor_list.html.haml +++ b/app/views/projects/_stat_anchor_list.html.haml @@ -4,5 +4,5 @@ %ul.nav - anchors.each do |anchor| %li.nav-item - = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.enabled ? 'nav-link stat-link' : "nav-link btn btn-#{anchor.class_modifier || 'missing'}" do - .stat-text= anchor.label + = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.is_link ? 'nav-link stat-link d-flex align-items-center' : "nav-link btn btn-#{anchor.class_modifier || 'missing'} d-flex align-items-center" do + .stat-text.d-flex.align-items-center= anchor.label diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml new file mode 100644 index 00000000000..d82a3dd70f9 --- /dev/null +++ b/app/views/projects/buttons/_clone.html.haml @@ -0,0 +1,31 @@ +- project = project || @project + +.git-clone-holder.js-git-clone-holder.input-group + - if allowed_protocols_present? + .input-group-text.clone-dropdown-btn.btn + %span.js-clone-dropdown-label + = enabled_project_button(project, enabled_protocol) + - else + %a#clone-dropdown.input-group-text.btn.btn-primary.btn-xs.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } + %span.append-right-4.js-clone-dropdown-label + = _('Clone') + = sprite_icon("arrow-down", css_class: "icon") + %form.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown + %li.pb-2 + %label.label-bold + = _('Clone with SSH') + .input-group + = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } + .input-group-append + = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") + = render_if_exists 'projects/buttons/geo' + %li + %label.label-bold + = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } + .input-group + = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } + .input-group-append + = clipboard_button(target: '#http_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") + = render_if_exists 'projects/buttons/geo' + += render_if_exists 'shared/geo_info_modal', project: project diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index f7551434d47..4eb53faa6ff 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -5,8 +5,8 @@ .project-action-button.dropdown.inline> %button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static' } = sprite_icon('download') - = icon("caret-down") %span.sr-only= _('Select Archive Format') + = sprite_icon("arrow-down") %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } %li.dropdown-header #{ _('Source code') } diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index 8da27ca7cb3..bc0a89bea62 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -1,9 +1,6 @@ - unless @project.empty_repo? - if current_user && can?(current_user, :fork_project, @project) .count-badge.d-inline-flex.align-item-stretch.append-right-8 - %span.fork-count.count-badge-count.d-flex.align-items-center - = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do - = @project.forks_count - if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2 = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center fork-btn' do = sprite_icon('fork', { css_class: 'icon' }) @@ -15,3 +12,6 @@ title: (s_('ProjectOverview|You have reached your project limit') unless can_create_fork) do = sprite_icon('fork', { css_class: 'icon' }) %span= s_('ProjectOverview|Fork') + %span.fork-count.count-badge-count.d-flex.align-items-center + = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do + = @project.forks_count diff --git a/app/views/projects/buttons/_notifications.html.haml b/app/views/projects/buttons/_notifications.html.haml new file mode 100644 index 00000000000..745983ace7e --- /dev/null +++ b/app/views/projects/buttons/_notifications.html.haml @@ -0,0 +1,27 @@ +- btn_class = local_assigns.fetch(:btn_class, "btn-xs") + +- if notification_setting + .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline + = form_for notification_setting, remote: true, html: { class: "inline notification-form no-label" } do |f| + = hidden_setting_source_input(notification_setting) + = hidden_field_tag "hide_label", true + = f.hidden_field :level, class: "notification_setting_level" + .js-notification-toggle-btns + %div{ class: ("btn-group" if notification_setting.custom?) } + - if notification_setting.custom? + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") + %span.js-notification-loading.fa.hidden + %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + = sprite_icon("arrow-down", css_class: "icon") + .sr-only Toggle dropdown + - else + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") + %span.js-notification-loading.fa.hidden + = sprite_icon("arrow-down", css_class: "icon") + + = render "shared/notifications/notification_dropdown", notification_setting: notification_setting + + = content_for :scripts_body do + = render "shared/notifications/custom_notifications", notification_setting: notification_setting diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index 0d04ecb3a58..090d1549aa7 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -1,19 +1,19 @@ - if current_user .count-badge.d-inline-flex.align-item-stretch.append-right-8 - %span.star-count.count-badge-count.d-flex.align-items-center - = @project.star_count - %button.count-badge-button.btn.btn-default.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } } + %button.count-badge-button.btn.btn-default.btn-xs.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } } - if current_user.starred?(@project) = sprite_icon('star', { css_class: 'icon' }) %span.starred= s_('ProjectOverview|Unstar') - else = sprite_icon('star-o', { css_class: 'icon' }) %span= s_('ProjectOverview|Star') + %span.star-count.count-badge-count.d-flex.align-items-center + = @project.star_count - else .count-badge.d-inline-flex.align-item-stretch.append-right-8 - %span.star-count.count-badge-count.d-flex.align-items-center - = @project.star_count - = link_to new_user_session_path, class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do + = link_to new_user_session_path, class: 'btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do = sprite_icon('star-o', { css_class: 'icon' }) %span= s_('ProjectOverview|Star') + %span.star-count.count-badge-count.d-flex.align-items-center + = @project.star_count diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml new file mode 100644 index 00000000000..778d27fc61d --- /dev/null +++ b/app/views/projects/cleanup/_show.html.haml @@ -0,0 +1,31 @@ +- return unless Feature.enabled?(:project_cleanup, @project) + +- expanded = Rails.env.test? + +%section.settings.no-animate#cleanup{ class: ('expanded' if expanded) } + .settings-header + %h4= _('Repository cleanup') + %button.btn.js-settings-toggle + = expanded ? _('Collapse') : _('Expand') + %p + = _("Clean up after running %{bfg} on the repository" % { bfg: link_to_bfg }).html_safe + = link_to icon('question-circle'), + help_page_path('user/project/repository/reducing_the_repo_size_using_git.md'), + target: '_blank', rel: 'noopener noreferrer' + + .settings-content + - url = cleanup_namespace_project_settings_repository_path(@project.namespace, @project) + = form_for @project, url: url, method: :post, authenticity_token: true, html: { class: 'js-requires-input' } do |f| + %fieldset.prepend-top-0.append-bottom-10 + .append-bottom-10 + %h5.prepend-top-0 + = _("Upload object map") + %button.btn.btn-default.js-choose-file{ type: "button" } + = _("Choose a file") + %span.prepend-left-default.js-filename + = _("No file selected") + = f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true + .form-text.text-muted + = _("The maximum file size allowed is %{max_attachment_size}mb") % { max_attachment_size: Gitlab::CurrentSettings.max_attachment_size } + = f.submit _('Start cleanup'), class: 'btn btn-success' + diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index c6789e32dbe..1a74b120c26 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -8,62 +8,50 @@ - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } - link = commit_path(project, commit, merge_request: merge_request) -- cache_key = [project.full_path, - ref, - commit.id, - Gitlab::CurrentSettings.current_application_settings, - @path.presence, - current_controller?(:commits), - merge_request&.iid, - view_details, - commit.status(ref), - I18n.locale].compact - -= cache(cache_key, expires_in: 1.day) do - %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } - - .avatar-cell.d-none.d-sm-block - = author_avatar(commit, size: 36, has_tooltip: false) - - .commit-detail.flex-list - .commit-content.qa-commit-content - - if view_details && merge_request - = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title" - - else - = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title") - %span.commit-row-message.d-block.d-sm-none - · - = commit.short_id - - if commit.status(ref) - .d-block.d-sm-none - = render_commit_status(commit, ref: ref) - - if commit.description? - %button.text-expander.js-toggle-button - = sprite_icon('ellipsis_h', size: 12) +%li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } + + .avatar-cell.d-none.d-sm-block + = author_avatar(commit, size: 36, has_tooltip: false) + + .commit-detail.flex-list + .commit-content.qa-commit-content + - if view_details && merge_request + = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title" + - else + = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title") + %span.commit-row-message.d-block.d-sm-none + · + = commit.short_id + - if commit.status(ref) + .d-block.d-sm-none + = render_commit_status(commit, ref: ref) + - if commit.description? + %button.text-expander.js-toggle-button + = sprite_icon('ellipsis_h', size: 12) - .committer - - commit_author_link = commit_author_link(commit, avatar: false, size: 24) - - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom') - - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } - #{ commit_text.html_safe } + .committer + - commit_author_link = commit_author_link(commit, avatar: false, size: 24) + - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom') + - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } + #{ commit_text.html_safe } - - if commit.description? - %pre.commit-row-description.js-toggle-content.append-bottom-8 - = preserve(markdown_field(commit, :description)) + - if commit.description? + %pre.commit-row-description.js-toggle-content.append-bottom-8 + = preserve(markdown_field(commit, :description)) - .commit-actions.flex-row.d-none.d-sm-flex - - if request.xhr? - = render partial: 'projects/commit/signature', object: commit.signature - - else - = render partial: 'projects/commit/ajax_signature', locals: { commit: commit } + .commit-actions.flex-row.d-none.d-sm-flex + - if request.xhr? + = render partial: 'projects/commit/signature', object: commit.signature + - else + = render partial: 'projects/commit/ajax_signature', locals: { commit: commit } - - if commit.status(ref) - = render_commit_status(commit, ref: ref) + - if commit.status(ref) + = render_commit_status(commit, ref: ref) - .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } } + .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } } - .commit-sha-group - .label.label-monospace - = commit.short_id - = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body") - = link_to_browse_code(project, commit) + .commit-sha-group + .label.label-monospace + = commit.short_id + = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body") + = link_to_browse_code(project, commit) diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index f376df29878..1b52821af15 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -53,7 +53,7 @@ = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git } .prepend-top-5.append-bottom-10 %button.btn.js-choose-project-avatar-button{ type: 'button' }= _("Choose file...") - %span.file_name.prepend-left-default.js-avatar-filename= _("No file chosen") + %span.file_name.prepend-left-default.js-filename= _("No file chosen") = f.file_field :avatar, class: "js-project-avatar-input hidden" .form-text.text-muted= _("The maximum file size allowed is 200KB.") - if @project.avatar? diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 936900a0087..aa690b12eb7 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -4,11 +4,10 @@ = render partial: 'flash_messages', locals: { project: @project } -= render "home_panel" +%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } + = render "home_panel" -.project-empty-note-panel - %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } - .prepend-top-20 + .project-empty-note-panel %h4.append-bottom-20 = _('The repository for this project is empty') @@ -32,66 +31,65 @@ = _('Otherwise it is recommended you start with one of the options below.') .prepend-top-20 -%nav.project-stats{ class: [container_class, ("limit-container-width" unless fluid_layout)] } - .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - .nav-links.scrolling-tabs.quick-links - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons + %nav.project-buttons + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + .nav-links.scrolling-tabs.quick-links + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons -- if can?(current_user, :push_code, @project) - %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } - .prepend-top-20 - .empty_wrapper - %h3#repo-command-line-instructions.page-title-empty - Command line instructions - .git-empty.js-git-empty - %fieldset - %h5 Git global setup - %pre.bg-light - :preserve - git config --global user.name "#{h git_user_name}" - git config --global user.email "#{h git_user_email}" + - if can?(current_user, :push_code, @project) + %div + .prepend-top-20 + .empty_wrapper + %h3#repo-command-line-instructions.page-title-empty + = _('Command line instructions') + .git-empty.js-git-empty + %fieldset + %h5= _('Git global setup') + %pre.bg-light + :preserve + git config --global user.name "#{h git_user_name}" + git config --global user.email "#{h git_user_email}" - %fieldset - %h5 Create a new repository - %pre.bg-light - :preserve - git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - cd #{h @project.path} - touch README.md - git add README.md - git commit -m "add README" - - if @project.can_current_user_push_to_default_branch? - %span>< - git push -u origin master + %fieldset + %h5= _('Create a new repository') + %pre.bg-light + :preserve + git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + cd #{h @project.path} + touch README.md + git add README.md + git commit -m "add README" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push -u origin master - %fieldset - %h5 Existing folder - %pre.bg-light - :preserve - cd existing_folder - git init - git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - git add . - git commit -m "Initial commit" - - if @project.can_current_user_push_to_default_branch? - %span>< - git push -u origin master + %fieldset + %h5= _('Existing folder') + %pre.bg-light + :preserve + cd existing_folder + git init + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + git add . + git commit -m "Initial commit" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push -u origin master - %fieldset - %h5 Existing Git repository - %pre.bg-light - :preserve - cd existing_repo - git remote rename origin old-origin - git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - - if @project.can_current_user_push_to_default_branch? - %span>< - git push -u origin --all - git push -u origin --tags + %fieldset + %h5= _('Existing Git repository') + %pre.bg-light + :preserve + cd existing_repo + git remote rename origin old-origin + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + - if @project.can_current_user_push_to_default_branch? + %span>< + git push -u origin --all + git push -u origin --tags - - if can? current_user, :remove_project, @project - .prepend-top-20 - = link_to 'Remove project', [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove float-right" + - if can? current_user, :remove_project, @project + .prepend-top-20 + = link_to _('Remove project'), [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove float-right" diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 996c7b1b960..82f035f24da 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,6 +1,6 @@ - page_title "Find File", @ref -.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, @options.merge(format: :json)))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @id || @commit.id)) } +.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @id || @commit.id)) } .nav-block .tree-ref-holder = render 'shared/ref_switcher', destination: 'find_file', path: @path diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index b44ea89510b..c63c34c4ebb 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -9,7 +9,7 @@ spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' } .dropdown - %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' } + %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.light sort: - if @sort.present? = sort_options_hash[@sort] diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml index 1c50cfbde85..bd0ab2c19f2 100644 --- a/app/views/projects/imports/new.html.haml +++ b/app/views/projects/imports/new.html.haml @@ -10,7 +10,7 @@ .card-body %pre :preserve - #{h(@project.import_error)} + #{h(@project.import_state.last_error)} = form_for @project, url: project_import_path(@project), method: :post do |f| = render "shared/import_form", f: f diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml index 3b0c828ccd1..422a3a22f87 100644 --- a/app/views/projects/imports/show.html.haml +++ b/app/views/projects/imports/show.html.haml @@ -1,4 +1,6 @@ - page_title import_in_progress_title +- @no_container = true +- @content_class = "limit-container-width" unless fluid_layout .save-project-loader .center diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml index b8ee4305142..b9d45e83032 100644 --- a/app/views/projects/labels/edit.html.haml +++ b/app/views/projects/labels/edit.html.haml @@ -1,4 +1,6 @@ - @no_container = true +- add_to_breadcrumbs "Labels", project_labels_path(@project) +- breadcrumb_title "Edit" - page_title "Edit", @label.name, "Labels" %div{ class: container_class } diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 2c6484c2c99..56b06374d6d 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -5,7 +5,7 @@ - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present? -- if @labels.present? && can_admin_label +- if labels_or_filters && can_admin_label - content_for(:header_content) do .nav-controls = link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new" diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml index 02f59f30a39..c6739231e36 100644 --- a/app/views/projects/labels/new.html.haml +++ b/app/views/projects/labels/new.html.haml @@ -1,5 +1,6 @@ - @no_container = true -- breadcrumb_title "Labels" +- add_to_breadcrumbs "Labels", project_labels_path(@project) +- breadcrumb_title "New" - page_title "New Label" %div{ class: container_class } diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml index af3f25c6a30..4006a468792 100644 --- a/app/views/projects/milestones/edit.html.haml +++ b/app/views/projects/milestones/edit.html.haml @@ -1,6 +1,9 @@ - @no_container = true +- breadcrumb_title "Edit" +- add_to_breadcrumbs "Milestones", project_milestones_path(@project) - page_title "Edit", @milestone.title, "Milestones" + %div{ class: container_class } %h3.page-title diff --git a/app/views/projects/milestones/new.html.haml b/app/views/projects/milestones/new.html.haml index c301f517013..01cc951e8c2 100644 --- a/app/views/projects/milestones/new.html.haml +++ b/app/views/projects/milestones/new.html.haml @@ -1,5 +1,6 @@ - @no_container = true -- breadcrumb_title "Milestones" +- add_to_breadcrumbs "Milestones", project_milestones_path(@project) +- breadcrumb_title "New" - page_title "New Milestone" %div{ class: container_class } diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml index 8dc042d87d1..293a2e3ebfe 100644 --- a/app/views/projects/mirrors/_authentication_method.html.haml +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -8,14 +8,14 @@ = f.label :auth_method, _('Authentication method'), class: 'label-bold' = f.select :auth_method, options_for_select(auth_options, mirror.auth_method), - {}, { class: "form-control js-mirror-auth-type" } + {}, { class: "form-control js-mirror-auth-type qa-authentication-method" } .form-group .collapse.js-well-changing-auth .changing-auth-method= icon('spinner spin lg') .well-password-auth.collapse.js-well-password-auth = f.label :password, _("Password"), class: "label-bold" - = f.password_field :password, value: mirror.password, class: 'form-control' + = f.password_field :password, value: mirror.password, class: 'form-control qa-password', autocomplete: 'new-password' - unless is_push .well-ssh-auth.collapse.js-well-ssh-auth %p.js-ssh-public-key-present{ class: ('collapse' unless ssh_public_key_present) } diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 2f9bd5b04b6..21b105e6f80 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -1,7 +1,7 @@ - expanded = Rails.env.test? - protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|') -%section.settings.project-mirror-settings.js-mirror-settings.no-animate#js-push-remote-settings{ class: ('expanded' if expanded) } +%section.settings.project-mirror-settings.js-mirror-settings.no-animate.qa-mirroring-repositories-settings#js-push-remote-settings{ class: ('expanded' if expanded) } .settings-header %h4= _('Mirroring repositories') %button.btn.js-settings-toggle @@ -20,7 +20,7 @@ .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' - = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+" + = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+" = render 'projects/mirrors/instructions' @@ -32,7 +32,7 @@ = link_to icon('question-circle'), help_page_path('user/project/protected_branches') .panel-footer - = f.submit _('Mirror repository'), class: 'btn btn-success', name: :update_remote_mirror + = f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror .panel.panel-default .table-responsive @@ -50,10 +50,10 @@ = render_if_exists 'projects/mirrors/table_pull_row' - @project.remote_mirrors.each_with_index do |mirror, index| - if mirror.enabled - %tr - %td= mirror.safe_url + %tr.qa-mirrored-repository-row + %td.qa-mirror-repository-url= mirror.safe_url %td= _('Push') - %td= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') + %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') %td - if mirror.last_error.present? .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error') diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml index a2cce83bfab..b49f1d9315e 100644 --- a/app/views/projects/mirrors/_mirror_repos_form.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml @@ -1,5 +1,5 @@ .form-group = label_tag :mirror_direction, _('Mirror direction'), class: 'label-light' - = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction', disabled: true + = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction qa-mirror-direction', disabled: true = render partial: "projects/mirrors/mirror_repos_push", locals: { f: f } diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 10e3b01096a..a760d02c4c3 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -50,7 +50,7 @@ .project-template .form-group %div - = render 'project_templates', f: f + = render 'project_templates', f: f, project: @project .tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' } - if import_sources_enabled? diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 2575efc0981..0f0114d513c 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -24,6 +24,38 @@ - if @pipeline.queued_duration = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" + .well-segment + .icon-container + = sprite_icon('flag') + - if @pipeline.latest? + %span.js-pipeline-url-latest.badge.badge-success.has-tooltip{ title: _("Latest pipeline for this branch") } + latest + - if @pipeline.has_yaml_errors? + %span.js-pipeline-url-yaml.badge.badge-danger.has-tooltip{ title: @pipeline.yaml_errors } + yaml invalid + - if @pipeline.failure_reason? + %span.js-pipeline-url-failure.badge.badge-danger.has-tooltip{ title: @pipeline.failure_reason } + error + - if @pipeline.auto_devops_source? + - popover_title_text = _('This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b>').html_safe + - popover_content_url = help_page_path('topics/autodevops/index.md') + - popover_content_text = _('Learn more about Auto DevOps') + %a.js-pipeline-url-autodevops.badge.badge-info.autodevops-badge{ href: "#", tabindex: "0", role: "button", data: { container: "body", + toggle: "popover", + placement: "top", + html: "true", + trigger: "focus", + title: "<div class='autodevops-title'>#{popover_title_text}</div>", + content: "<a class='autodevops-link' href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>", + } } + Auto DevOps + - if @pipeline.merge_request? + %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: "This pipeline is run in a merge request context" } + merge request + - if @pipeline.stuck? + %span.js-pipeline-url-stuck.badge.badge-warning + stuck + .well-segment.branch-info .icon-container.commit-icon = custom_icon("icon_commit") diff --git a/app/views/projects/project_templates/_built_in_templates.html.haml b/app/views/projects/project_templates/_built_in_templates.html.haml index 233c3adba0e..5b4d8927045 100644 --- a/app/views/projects/project_templates/_built_in_templates.html.haml +++ b/app/views/projects/project_templates/_built_in_templates.html.haml @@ -9,9 +9,9 @@ .text-muted = template.description .controls.d-flex.align-items-center - %label.btn.btn-success.template-button.choose-template.append-right-10.append-bottom-0{ for: template.name } + %a.btn.btn-default.append-right-10{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } } + = _("Preview") + %label.btn.btn-success.template-button.choose-template.append-bottom-0{ for: template.name } %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } } %span = _("Use template") - %a.btn.btn-default{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } } - = _("Preview") diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml new file mode 100644 index 00000000000..f650fa0f38f --- /dev/null +++ b/app/views/projects/serverless/functions/index.html.haml @@ -0,0 +1,15 @@ +- @no_container = true +- @content_class = "limit-container-width" unless fluid_layout +- breadcrumb_title 'Serverless' +- page_title 'Serverless' +- status_path = project_serverless_functions_path(@project, format: :json) +- clusters_path = project_clusters_path(@project) + +.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } } + +%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } + .js-serverless-functions-notice + .flash-container + + .top-area.adjust + .serverless-functions-table#js-serverless-functions diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 621b7922072..bb328f5344c 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -29,7 +29,7 @@ = f.label :build_timeout_human_readable, _('Timeout'), class: 'label-bold' = f.text_field :build_timeout_human_readable, class: 'form-control' %p.form-text.text-muted - = _("Per job. If a job passes this threshold, it will be marked as failed") + = _('If any job surpasses this timeout threshold, it will be marked as failed. Human readable time input language is accepted like "1 hour". Values without specification represent seconds.') = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' %hr diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index c14e95a382c..cb3a035c49e 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -13,3 +13,4 @@ = render "projects/protected_tags/index" = render @deploy_keys = render "projects/deploy_tokens/index" += render "projects/cleanup/show" diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index f29ce4f5c06..c87a084740b 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -1,7 +1,6 @@ - @no_container = true - breadcrumb_title _("Details") - @content_class = "limit-container-width" unless fluid_layout -- show_auto_devops_callout = show_auto_devops_callout?(@project) = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") @@ -15,20 +14,11 @@ %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } = render "projects/last_push" -= render "home_panel" - -- if can?(current_user, :download_code, @project) - %nav.project-stats{ class: [container_class, ("limit-container-width" unless fluid_layout)] } - .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - .nav-links.scrolling-tabs.quick-links - = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) - = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout) + = render "home_panel" + - if can?(current_user, :download_code, @project) && @project.repository_languages.present? = repository_languages_bar(@project.repository_languages) -%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } - if @project.archived? .text-warning.center.prepend-top-20 %p @@ -41,4 +31,4 @@ = render 'shared/auto_devops_callout' %div{ class: project_child_container_class(view_path) } - = render view_path + = render view_path, is_project_overview: true diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 37535370940..026bc44a05f 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -14,7 +14,7 @@ = search_field_tag :search, params[:search], { placeholder: s_('TagsPage|Filter by tag name'), id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false } .dropdown - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown'} } %span.light = tags_sort_options_hash[@sort] = icon('chevron-down') diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index 5e0523f0b96..889a13339fd 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -1,5 +1,5 @@ .tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } - .table-holder + .table-holder.bordered-box %table.table#tree-slider{ class: "table_#{@hex_path} tree-table qa-file-tree" } %thead %tr diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 601e3f25852..a89df6adfb3 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -85,4 +85,8 @@ = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do = _('Web IDE') + - if show_xcode_link?(@project) + .project-action-button.project-xcode.inline + = render "projects/buttons/xcode_link" + = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/app/views/projects/wikis/_sidebar_wiki_page.html.haml b/app/views/projects/wikis/_sidebar_wiki_page.html.haml index 2423ac6abce..769d869bd53 100644 --- a/app/views/projects/wikis/_sidebar_wiki_page.html.haml +++ b/app/views/projects/wikis/_sidebar_wiki_page.html.haml @@ -1,3 +1,3 @@ %li{ class: active_when(params[:id] == wiki_page.slug) } = link_to project_wiki_path(@project, wiki_page) do - = wiki_page.title.capitalize + = wiki_page.human_title diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml index 80aa1500d53..26671a7b7d2 100644 --- a/app/views/projects/wikis/edit.html.haml +++ b/app/views/projects/wikis/edit.html.haml @@ -1,5 +1,7 @@ - @content_class = "limit-container-width" unless fluid_layout -- page_title _("Edit"), @page.title.capitalize, _("Wiki") +- add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, @page) +- breadcrumb_title @page.persisted? ? _("Edit") : _("New") +- page_title @page.persisted? ? _("Edit") : _("New"), @page.human_title, _("Wiki") = wiki_page_errors(@error) @@ -10,9 +12,9 @@ .nav-text %h2.wiki-page-title - if @page.persisted? - = link_to @page.title.capitalize, project_wiki_path(@project, @page) + = link_to @page.human_title, project_wiki_path(@project, @page) - else - = @page.title.capitalize + = @page.human_title %span.light · - if @page.persisted? @@ -28,7 +30,7 @@ = link_to project_wiki_history_path(@project, @page), class: "btn" do = s_("Wiki|Page history") - if can?(current_user, :admin_wiki, @project) - #delete-wiki-modal-wrapper{ data: { delete_wiki_url: project_wiki_path(@project, @page), page_title: @page.title.capitalize } } + #delete-wiki-modal-wrapper{ data: { delete_wiki_url: project_wiki_path(@project, @page), page_title: @page.human_title } } = render 'form', uploads_path: wiki_attachment_upload_url diff --git a/app/views/projects/wikis/history.html.haml b/app/views/projects/wikis/history.html.haml index 969a1677d9a..c5fbeeafa54 100644 --- a/app/views/projects/wikis/history.html.haml +++ b/app/views/projects/wikis/history.html.haml @@ -1,4 +1,4 @@ -- page_title _("History"), @page.title.capitalize, _("Wiki") +- page_title _("History"), @page.human_title, _("Wiki") .wiki-page-header.has-sidebar-toggle %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } @@ -6,7 +6,7 @@ .nav-text %h2.wiki-page-title - = link_to @page.title.capitalize, project_wiki_path(@project, @page) + = link_to @page.human_title, project_wiki_path(@project, @page) %span.light · = _("History") diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index fbf248c2058..cc38ec12fd8 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -1,7 +1,7 @@ - @content_class = "limit-container-width" unless fluid_layout -- breadcrumb_title @page.title.capitalize +- breadcrumb_title @page.human_title - wiki_breadcrumb_dropdown_links(@page.slug) -- page_title @page.title.capitalize, _("Wiki") +- page_title @page.human_title, _("Wiki") - add_to_breadcrumbs _("Wiki"), get_project_wiki_path(@project) .wiki-page-header.has-sidebar-toggle @@ -9,7 +9,7 @@ = icon('angle-double-left') .nav-text - %h2.wiki-page-title= @page.title.capitalize + %h2.wiki-page-title= @page.human_title %span.wiki-last-edit-by - if @page.last_version = (_("Last edited by %{name}") % { name: "<strong>#{@page.last_version.author_name}</strong>" }).html_safe diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index a8d4d4af93a..2a602095845 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -1,7 +1,7 @@ - project = find_project_for_result_blob(blob) - return unless project -- file_name, blob = parse_search_result(blob) -- blob_link = project_blob_path(project, tree_join(blob.ref, file_name)) +- blob = parse_search_result(blob) +- blob_link = project_blob_path(project, tree_join(blob.ref, blob.filename)) -= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: file_name, blob_link: blob_link } += render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: blob.filename, blob_link: blob_link } diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index 4346217c230..389e4cc75b9 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -1,5 +1,5 @@ - project = find_project_for_result_blob(wiki_blob) -- file_name, wiki_blob = parse_search_result(wiki_blob) +- wiki_blob = parse_search_result(wiki_blob) - wiki_blob_link = project_wiki_path(project, wiki_blob.basename) -= render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: file_name, blob_link: wiki_blob_link } += render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: wiki_blob.filename, blob_link: wiki_blob_link } diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index 6c4607b2f16..0d0a3c1aa64 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -1,6 +1,6 @@ - if show_auto_devops_implicitly_enabled_banner?(project) .auto-devops-implicitly-enabled-banner.alert.alert-warning - - more_information_link = link_to _('More information'), 'https://docs.gitlab.com/ee/topics/autodevops/', class: 'alert-link' + - more_information_link = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link' - auto_devops_message = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found. %{more_information_link}") % { more_information_link: more_information_link } = auto_devops_message.html_safe .alert-link-group diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml index a6ba3b59365..bd68a3e4c84 100644 --- a/app/views/shared/_milestones_sort_dropdown.html.haml +++ b/app/views/shared/_milestones_sort_dropdown.html.haml @@ -1,5 +1,5 @@ .dropdown.inline.prepend-left-10 - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown' } } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } %span.light - if @sort.present? = milestone_sort_options_hash[@sort] diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml index 998985cabe1..b43662947a8 100644 --- a/app/views/shared/_mobile_clone_panel.html.haml +++ b/app/views/shared/_mobile_clone_panel.html.haml @@ -1,13 +1,13 @@ - project = project || @project - ssh_copy_label = _("Copy SSH clone URL") -- http_copy_label = _("Copy HTTPS clone URL") +- http_copy_label = _('Copy %{http_label} clone URL') % { http_label: gitlab_config.protocol.upcase } -.btn-group.mobile-git-clone.js-mobile-git-clone - = clipboard_button(button_text: default_clone_label, target: '#project_clone', hide_button_icon: true, class: "input-group-text clone-dropdown-btn js-clone-dropdown-label btn btn-default") - %button.btn.btn-default.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { toggle: "dropdown" } } - = icon("caret-down", class: "dropdown-btn-icon") +.btn-group.mobile-git-clone.js-mobile-git-clone.btn-block + = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "btn-primary flex-fill bold justify-content-center input-group-text clone-dropdown-btn js-clone-dropdown-label") + %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { toggle: "dropdown" } } + = sprite_icon("arrow-down", css_class: "dropdown-btn-icon icon") %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } } %li - = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' }) + = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' }, default: true) %li = dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' }) diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml index f32cff18fa8..721a2af8069 100644 --- a/app/views/shared/_remote_mirror_update_button.html.haml +++ b/app/views/shared/_remote_mirror_update_button.html.haml @@ -2,5 +2,5 @@ %button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body' }, title: _('Updating') } = icon("refresh spin") - else - = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do + = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn qa-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do = icon("refresh") diff --git a/app/views/shared/_sort_dropdown.html.haml b/app/views/shared/_sort_dropdown.html.haml deleted file mode 100644 index be6d4f1c32b..00000000000 --- a/app/views/shared/_sort_dropdown.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -- sorted_by = sort_options_hash[@sort] -- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues' - -.dropdown.inline.prepend-left-10 - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } - = sorted_by - = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort - %li - = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority, label: true), sorted_by) - = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sorted_by) - = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sorted_by) - = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone, label: true), sorted_by) - = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date, label: true), sorted_by) if viewing_issues - = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity, label: true), sorted_by) - = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority, label: true), sorted_by) diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 2237b93a10b..1ae6d1f5ee3 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -9,7 +9,7 @@ - default_sort_by = sort_value_recently_created .dropdown.inline.js-group-filter-dropdown-wrap.append-right-10 - %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' } + %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.dropdown-label = options_hash[default_sort_by] = icon('chevron-down') diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml new file mode 100644 index 00000000000..2ca4657851c --- /dev/null +++ b/app/views/shared/issuable/_filter.html.haml @@ -0,0 +1,32 @@ +.issues-filters + .issues-details-filters.row-content-block.second-block + = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do + - if params[:search].present? + = hidden_field_tag :search, params[:search] + .issues-other-filters + .filter-item.inline + - if params[:author_id].present? + = hidden_field_tag(:author_id, params[:author_id]) + = dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit", + placeholder: "Search authors", data: { any_user: "Any Author", first_user: current_user&.username, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:author_id], field_name: "author_id", default_label: "Author" } }) + + .filter-item.inline + - if params[:assignee_id].present? + = hidden_field_tag(:assignee_id, params[:assignee_id]) + = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user&.username, null_user: true, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) + + .filter-item.inline.milestone-filter + = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true + + .filter-item.inline.labels-filter + = render "shared/issuable/label_dropdown", selected: selected_labels, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" } + + - unless @no_filters_set + .float-right + = render 'shared/issuable/sort_dropdown' + + - has_labels = @labels && @labels.any? + .row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) } + - if has_labels + = render 'shared/labels_row', labels: @labels diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 6939aba6896..46634693067 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -2,7 +2,6 @@ - board = local_assigns.fetch(:board, nil) - block_css_class = type != :boards_modal ? 'row-content-block second-block' : '' - user_can_admin_list = board && can?(current_user, :admin_list, board.parent) -- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true) .issues-filters .issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal } @@ -95,7 +94,10 @@ %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link{ type: 'button' } - = _('No Label') + = _('None') + %li.filter-dropdown-item{ data: { value: 'any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') %li.divider.droplab-item-ignore %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item @@ -139,5 +141,5 @@ - if @project #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } #js-toggle-focus-btn - - elsif show_sorting_dropdown - = render 'shared/sort_dropdown' + - elsif type != :boards_modal + = render 'shared/issuable/sort_dropdown' diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml new file mode 100644 index 00000000000..c211b9fcaa2 --- /dev/null +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -0,0 +1,20 @@ +- sort_value = @sort +- sort_title = issuable_sort_option_title(sort_value) +- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues' + +.dropdown.inline.prepend-left-10.issue-sort-dropdown + .btn-group{ role: 'group' } + .btn-group{ role: 'group' } + %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } + = sort_title + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort + %li + = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority, label: true), sort_title) + = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sort_title) + = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sort_title) + = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone, label: true), sort_title) + = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date, label: true), sort_title) if viewing_issues + = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity, label: true), sort_title) + = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority, label: true), sort_title) + = issuable_sort_direction_button(sort_value) diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml index 8a7d037e15b..d664ef1cc2f 100644 --- a/app/views/shared/labels/_sort_dropdown.html.haml +++ b/app/views/shared/labels/_sort_dropdown.html.haml @@ -1,6 +1,6 @@ - sort_title = label_sort_options_hash[@sort] || sort_title_name_desc .dropdown.inline - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown' } } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } = sort_title = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml new file mode 100644 index 00000000000..f7227b9101e --- /dev/null +++ b/app/views/shared/members/_access_request_links.html.haml @@ -0,0 +1,17 @@ +- model_name = source.model_name.to_s.downcase + +- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id)) # rubocop: disable CodeReuse/ActiveRecord + - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project') + = link_to link_text, polymorphic_path([:leave, source, :members]), + method: :delete, + data: { confirm: leave_confirmation_message(source) }, + class: 'access-request-link' +- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord + = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), + method: :delete, + data: { confirm: remove_member_message(requester) }, + class: 'access-request-link' +- elsif source.request_access_enabled && can?(current_user, :request_access, source) + = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]), + method: :post, + class: 'access-request-link' diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index a7fd75d85d7..6b3841ebbc4 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -75,7 +75,7 @@ = dropdown_title(_("Change permissions")) .dropdown-content %ul - - member.access_level_roles.each do |role, role_id| + - member.valid_level_roles.each do |role, role_id| %li = link_to role, "javascript:void(0)", class: ("is-active" if member.access_level == role_id), diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 3dd2842be4f..ed7fefba56d 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -35,8 +35,8 @@ .col-sm-2 .milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end - if @project - - if can?(current_user, :admin_milestone, milestone.project) and milestone.active? - - if @project.group + - if can_admin_project_milestones? and milestone.active? + - if can_admin_group_milestones? %button.js-promote-project-milestone-button.btn.btn-blank.btn-sm.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'), disabled: true, type: 'button', diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index f6c7ca70ebd..30860988bbb 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -1,3 +1,5 @@ +- btn_class = local_assigns.fetch(:btn_class, nil) + - if notification_setting .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline = form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f| @@ -6,14 +8,14 @@ .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = icon('caret-down') .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) = icon("caret-down") diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml index daf08d9bb2c..559b5aa9c1e 100644 --- a/app/views/shared/runners/_form.html.haml +++ b/app/views/shared/runners/_form.html.haml @@ -45,7 +45,7 @@ = _('Maximum job timeout') .col-sm-10 = f.text_field :maximum_timeout_human_readable, class: 'form-control' - .form-text.text-muted= _('This timeout will take precedence when lower than Project-defined timeout') + .form-text.text-muted= _('This timeout will take precedence when lower than project-defined timeout and accepts a human readable time input language like "1 hour". Values without specification represent seconds.') .form-group.row = label_tag :tag_list, class: 'col-form-label col-sm-2' do = _('Tags') diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index 0ce13ee7a53..ef8664e6f47 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -3,31 +3,31 @@ .d-none.d-sm-block - if can?(current_user, :update_personal_snippet, @snippet) = link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do - Edit + = _("Edit") - if can?(current_user, :admin_personal_snippet, @snippet) - = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do - Delete - = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-success", title: "New snippet" do - New snippet + = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do + = _("Delete") + = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: _("New snippet") do + = _("New snippet") - if @snippet.submittable_as_spam_by?(current_user) - = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: 'Submit as spam' + = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam') .d-block.d-sm-none.dropdown %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } - Options + = _("Options") = icon('caret-down') .dropdown-menu.dropdown-menu-full-width %ul %li - = link_to new_snippet_path, title: "New snippet" do - New snippet + = link_to new_snippet_path, title: _("New snippet") do + = _("New snippet") - if can?(current_user, :admin_personal_snippet, @snippet) %li - = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do - Delete + = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do + = _("Delete") - if can?(current_user, :update_personal_snippet, @snippet) %li = link_to edit_snippet_path(@snippet) do - Edit + = _("Edit") - if @snippet.submittable_as_spam_by?(current_user) %li - = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post + = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml index dfea8b40bd8..69d41f8fe5e 100644 --- a/app/views/snippets/_snippets.html.haml +++ b/app/views/snippets/_snippets.html.haml @@ -5,6 +5,6 @@ = render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project } - if @snippets.empty? %li - .nothing-here-block Nothing here. + .nothing-here-block= _("Nothing here.") = paginate @snippets, theme: 'gitlab' diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml index dc4b0fd9ba0..c312226dd6c 100644 --- a/app/views/snippets/_snippets_scope_menu.html.haml +++ b/app/views/snippets/_snippets_scope_menu.html.haml @@ -4,7 +4,7 @@ .nav-links.snippet-scope-menu.mobile-separator.nav.nav-tabs %li{ class: active_when(params[:scope].nil?) } = link_to subject_snippets_path(subject) do - All + = _("All") %span.badge.badge-pill - if include_private = subject.snippets.count @@ -14,18 +14,18 @@ - if include_private %li{ class: active_when(params[:scope] == "are_private") } = link_to subject_snippets_path(subject, scope: 'are_private') do - Private + = _("Private") %span.badge.badge-pill = subject.snippets.are_private.count %li{ class: active_when(params[:scope] == "are_internal") } = link_to subject_snippets_path(subject, scope: 'are_internal') do - Internal + = _("Internal") %span.badge.badge-pill = subject.snippets.are_internal.count %li{ class: active_when(params[:scope] == "are_public") } = link_to subject_snippets_path(subject, scope: 'are_public') do - Public + = _("Public") %span.badge.badge-pill = subject.snippets.are_public.count diff --git a/app/views/snippets/edit.html.haml b/app/views/snippets/edit.html.haml index 18ebeb78f87..ebc6c0a2605 100644 --- a/app/views/snippets/edit.html.haml +++ b/app/views/snippets/edit.html.haml @@ -1,5 +1,6 @@ -- page_title "Edit", "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" +- page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") + %h3.page-title - Edit Snippet + = _("Edit Snippet") %hr = render 'shared/snippets/form', url: snippet_path(@snippet) diff --git a/app/views/snippets/index.html.haml b/app/views/snippets/index.html.haml index 9b4a7dbe68d..4f418e2381f 100644 --- a/app/views/snippets/index.html.haml +++ b/app/views/snippets/index.html.haml @@ -1,13 +1,13 @@ -- page_title "By #{@user.name}", "Snippets" +- page_title _("By %{user_name}") % { user_name: @user.name }, _("Snippets") %ol.breadcrumb %li.breadcrumb-item = link_to snippets_path do - Snippets + = _("Snippets") %li.breadcrumb-item = @user.name .float-right.d-none.d-sm-block = link_to user_path(@user) do - #{@user.name} profile page + = _("%{user_name} profile page") % { user_name: @user.name } = render 'snippets' diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml index 6bc748d346e..114c777bdc2 100644 --- a/app/views/snippets/new.html.haml +++ b/app/views/snippets/new.html.haml @@ -1,6 +1,6 @@ - @hide_top_links = true - @hide_breadcrumbs = true -- page_title "New Snippet" +- page_title _("New Snippet") .page-title-holder %h1.page-title= _('New Snippet') diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index 220ba2b49e6..01b95145937 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -1,7 +1,7 @@ - if current_user - if note.emoji_awardable? .note-actions-item - = link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip", data: { position: 'right' } do + = link_to '#', title: _('Add reaction'), class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') @@ -9,7 +9,7 @@ - if note_editable .note-actions-item - = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do + = button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do %span.link-highlight = custom_icon('icon_pencil') diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 578327883e5..36b4e00e8d5 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -1,8 +1,8 @@ - @hide_top_links = true - @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout -- add_to_breadcrumbs "Snippets", dashboard_snippets_path +- add_to_breadcrumbs _("Snippets"), dashboard_snippets_path - breadcrumb_title @snippet.to_reference -- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" +- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") = render 'shared/snippets/header' diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index 938cb579e9f..01acbf8eadd 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -7,7 +7,7 @@ %li %span.light %i.fa.fa-clock-o - = event.created_at.strftime('%-I:%M%P') + = event.created_at.to_time.in_time_zone.strftime('%-I:%M%P') - if event.visible_to_user?(current_user) - if event.push? #{event.action_name} #{event.ref_type} diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index c0b410472eb..672c77539af 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -10,7 +10,6 @@ - cronjob:prune_old_events - cronjob:remove_expired_group_links - cronjob:remove_expired_members -- cronjob:remove_old_web_hook_logs - cronjob:remove_unreferenced_lfs_objects - cronjob:repository_archive_cache - cronjob:repository_check_dispatch @@ -29,6 +28,7 @@ - gcp_cluster:wait_for_cluster_creation - gcp_cluster:cluster_wait_for_ingress_ip_address - gcp_cluster:cluster_platform_configure +- gcp_cluster:cluster_project_configure - github_import_advance_stage - github_importer:github_import_import_diff_note @@ -132,3 +132,5 @@ - create_note_diff_file - delete_diff_files - detect_repository_languages +- repository_cleanup +- delete_stored_files diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb index c1283e9b2fc..4a9becf0ca7 100644 --- a/app/workers/archive_trace_worker.rb +++ b/app/workers/archive_trace_worker.rb @@ -7,7 +7,7 @@ class ArchiveTraceWorker # rubocop: disable CodeReuse/ActiveRecord def perform(job_id) Ci::Build.without_archived_trace.find_by(id: job_id).try do |job| - job.trace.archive! + Ci::ArchiveTraceService.new.execute(job) end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb index 7443aad1380..f65ff239866 100644 --- a/app/workers/ci/archive_traces_cron_worker.rb +++ b/app/workers/ci/archive_traces_cron_worker.rb @@ -11,21 +11,9 @@ module Ci # This could happen when ArchiveTraceWorker sidekiq jobs were lost by receiving SIGKILL # More details in https://gitlab.com/gitlab-org/gitlab-ce/issues/36791 Ci::Build.finished.with_live_trace.find_each(batch_size: 100) do |build| - begin - build.trace.archive! - rescue ::Gitlab::Ci::Trace::AlreadyArchivedError - rescue => e - failed_archive_counter.increment - Rails.logger.error "Failed to archive stale live trace. id: #{build.id} message: #{e.message}" - end + Ci::ArchiveTraceService.new.execute(build) end end # rubocop: enable CodeReuse/ActiveRecord - - private - - def failed_archive_counter - @failed_archive_counter ||= Gitlab::Metrics.counter(:job_trace_archive_failed_total, "Counter of failed attempts of traces archiving") - end end end diff --git a/app/workers/cluster_platform_configure_worker.rb b/app/workers/cluster_platform_configure_worker.rb index 8f3689f0166..aa7570caa79 100644 --- a/app/workers/cluster_platform_configure_worker.rb +++ b/app/workers/cluster_platform_configure_worker.rb @@ -6,17 +6,7 @@ class ClusterPlatformConfigureWorker def perform(cluster_id) Clusters::Cluster.find_by_id(cluster_id).try do |cluster| - next unless cluster.cluster_project - - kubernetes_namespace = cluster.find_or_initialize_kubernetes_namespace(cluster.cluster_project) - - Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService.new( - cluster: cluster, - kubernetes_namespace: kubernetes_namespace - ).execute + Clusters::RefreshService.create_or_update_namespaces_for_cluster(cluster) end - - rescue ::Kubeclient::HttpError => err - Rails.logger.error "Failed to create/update Kubernetes namespace for cluster_id: #{cluster_id} with error: #{err.message}" end end diff --git a/app/workers/cluster_project_configure_worker.rb b/app/workers/cluster_project_configure_worker.rb new file mode 100644 index 00000000000..497e57c0d0b --- /dev/null +++ b/app/workers/cluster_project_configure_worker.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ClusterProjectConfigureWorker + include ApplicationWorker + include ClusterQueue + + def perform(project_id) + project = Project.find(project_id) + + ::Clusters::RefreshService.create_or_update_namespaces_for_project(project) + end +end diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index 59e6bc2c97d..e2dee315cde 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -24,7 +24,7 @@ module Gitlab def find_project(id) # If the project has been marked as failed we want to bail out # automatically. - Project.import_started.find_by(id: id) + Project.joins_import_state.where(import_state: { status: :started }).find_by(id: id) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/concerns/project_import_options.rb b/app/workers/concerns/project_import_options.rb index 22bdf441d6b..2baf768bfd1 100644 --- a/app/workers/concerns/project_import_options.rb +++ b/app/workers/concerns/project_import_options.rb @@ -18,7 +18,7 @@ module ProjectImportOptions "import" end - project.mark_import_as_failed("Every #{action} attempt has failed: #{job['error_message']}. Please try again.") + project.import_state.mark_as_failed(_("Every %{action} attempt has failed: %{job_error_message}. Please try again.") % { action: action, job_error_message: job['error_message'] }) Sidekiq.logger.warn "Failed #{job['class']} with #{job['args']}: #{job['error_message']}" end end diff --git a/app/workers/concerns/project_start_import.rb b/app/workers/concerns/project_start_import.rb index 46a133db2a1..4462bc51a24 100644 --- a/app/workers/concerns/project_start_import.rb +++ b/app/workers/concerns/project_start_import.rb @@ -2,11 +2,11 @@ # Used in EE by mirroring module ProjectStartImport - def start(project) - if project.import_started? && project.import_jid == self.jid + def start(import_state) + if import_state.started? && import_state.jid == self.jid return true end - project.import_start + import_state.start end end diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb new file mode 100644 index 00000000000..ff7931849d8 --- /dev/null +++ b/app/workers/delete_stored_files_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class DeleteStoredFilesWorker + include ApplicationWorker + + def perform(class_name, keys) + klass = begin + class_name.constantize + rescue NameError + nil + end + + unless klass + message = "Unknown class '#{class_name}'" + logger.error(message) + Gitlab::Sentry.track_exception(RuntimeError.new(message)) + return + end + + klass.new(logger: logger).delete_keys(keys) + end +end diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index 2b49860025a..0b3437a8a33 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -31,7 +31,7 @@ module Gitlab # next_stage - The name of the next stage to start when all jobs have been # completed. def perform(project_id, waiters, next_stage) - return unless (project = find_project(project_id)) + return unless import_state = find_import_state(project_id) new_waiters = wait_for_jobs(waiters) @@ -41,7 +41,7 @@ module Gitlab # the pressure on Redis. We _only_ do this once all jobs are done so # we don't get stuck forever if one or more jobs failed to notify the # JobWaiter. - project.refresh_import_jid_expiration + import_state.refresh_jid_expiration STAGES.fetch(next_stage.to_sym).perform_async(project_id) else @@ -64,11 +64,8 @@ module Gitlab end # rubocop: disable CodeReuse/ActiveRecord - def find_project(id) - # TODO: Only select the JID - # This is due to the fact that the JID could be present in either the project record or - # its associated import_state record - Project.import_started.find_by(id: id) + def find_import_state(project_id) + ProjectImportState.select(:jid).with_status(:started).find_by(project_id: project_id) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb index 65473026b4c..76723e4a61f 100644 --- a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb +++ b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb @@ -16,12 +16,13 @@ module Gitlab # project_id - The ID of the project that is being imported. # check_job_id - The ID of the job for which to check the status. def perform(project_id, check_job_id) - return unless (project = find_project(project_id)) + import_state = find_import_state(project_id) + return unless import_state if SidekiqStatus.running?(check_job_id) # As long as the repository is being cloned we want to keep refreshing # the import JID status. - project.refresh_import_jid_expiration + import_state.refresh_jid_expiration self.class.perform_in_the_future(project_id, check_job_id) end @@ -31,11 +32,10 @@ module Gitlab end # rubocop: disable CodeReuse/ActiveRecord - def find_project(id) - # TODO: Only select the JID - # This is due to the fact that the JID could be present in either the project record or - # its associated import_state record - Project.import_started.find_by(id: id) + def find_import_state(project_id) + ProjectImportState.select(:jid) + .with_status(:started) + .find_by(project_id: project_id) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb index 5726fbb573d..ccfed2ae187 100644 --- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb @@ -23,7 +23,7 @@ module Gitlab klass.new(project, client).execute end - project.refresh_import_jid_expiration + project.import_state.refresh_jid_expiration ImportPullRequestsWorker.perform_async(project.id) end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb index 1c5a7139802..37a7a7f4ba0 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb @@ -15,7 +15,7 @@ module Gitlab .new(project, client) .execute - project.refresh_import_jid_expiration + project.import_state.refresh_jid_expiration AdvanceStageWorker.perform_async( project.id, diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index 42f5b945a75..98f9f45e608 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -8,11 +8,18 @@ class NewNoteWorker # rubocop: disable CodeReuse/ActiveRecord def perform(note_id, _params = {}) if note = Note.find_by(id: note_id) - NotificationService.new.new_note(note) + NotificationService.new.new_note(note) unless skip_notification?(note) Notes::PostProcessService.new(note).execute else Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job") end end + + private + + # EE-only method + def skip_notification?(note) + false + end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index 85d1ffe0fa9..ac4e9710f33 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -9,18 +9,36 @@ class PipelineScheduleWorker Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) .preload(:owner, :project).find_each do |schedule| begin - pipeline = Ci::CreatePipelineService.new(schedule.project, - schedule.owner, - ref: schedule.ref) - .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) - - schedule.deactivate! unless pipeline.persisted? + Ci::CreatePipelineService.new(schedule.project, + schedule.owner, + ref: schedule.ref) + .execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule) rescue => e - Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}" + error(schedule, e) ensure schedule.schedule_next_run! end end end # rubocop: enable CodeReuse/ActiveRecord + + private + + def error(schedule, error) + failed_creation_counter.increment + + Rails.logger.error "Failed to create a scheduled pipeline. " \ + "schedule_id: #{schedule.id} message: #{error.message}" + + Gitlab::Sentry + .track_exception(error, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', + extra: { schedule_id: schedule.id }) + end + + def failed_creation_counter + @failed_creation_counter ||= + Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total, + "Counter of failed attempts of pipeline schedule creation") + end end diff --git a/app/workers/remove_old_web_hook_logs_worker.rb b/app/workers/remove_old_web_hook_logs_worker.rb deleted file mode 100644 index 0f486f8991d..00000000000 --- a/app/workers/remove_old_web_hook_logs_worker.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class RemoveOldWebHookLogsWorker - include ApplicationWorker - include CronjobQueue - - WEB_HOOK_LOG_LIFETIME = 2.days - - # rubocop: disable DestroyAll - def perform - WebHookLog.destroy_all(['created_at < ?', Time.now - WEB_HOOK_LOG_LIFETIME]) - end - # rubocop: enable DestroyAll -end diff --git a/app/workers/repository_cleanup_worker.rb b/app/workers/repository_cleanup_worker.rb new file mode 100644 index 00000000000..aa26c173a72 --- /dev/null +++ b/app/workers/repository_cleanup_worker.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class RepositoryCleanupWorker + include ApplicationWorker + + sidekiq_options retry: 3 + + sidekiq_retries_exhausted do |msg, err| + next if err.is_a?(ActiveRecord::RecordNotFound) + + args = msg['args'] + [msg['error_message']] + + new.perform_failure(*args) + end + + def perform(project_id, user_id) + project = Project.find(project_id) + user = User.find(user_id) + + Projects::CleanupService.new(project, user).execute + + notification_service.repository_cleanup_success(project, user) + end + + def perform_failure(project_id, user_id, error) + project = Project.find(project_id) + user = User.find(user_id) + + # Ensure the file is removed + project.bfg_object_map.remove! + notification_service.repository_cleanup_failure(project, user, error) + end + + private + + def notification_service + @notification_service ||= NotificationService.new + end +end diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 68ec66e8499..7eae07d3f6b 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -12,7 +12,7 @@ class RepositoryForkWorker source_project = target_project.forked_from_project unless source_project - return target_project.mark_import_as_failed('Source project cannot be found.') + return target_project.import_state.mark_as_failed(_('Source project cannot be found.')) end fork_repository(target_project, source_project.repository_storage, source_project.disk_path) @@ -33,7 +33,7 @@ class RepositoryForkWorker end def start_fork(project) - return true if start(project) + return true if start(project.import_state) Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.") false diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 82189a3c9f5..59691f48a39 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -34,14 +34,14 @@ class RepositoryImportWorker attr_reader :project def start_import - return true if start(project) + return true if start(project.import_state) Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while importing.") false end def fail_import(message) - project.mark_import_as_failed(message) + project.import_state.mark_as_failed(message) end def template_import? diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb index 667a4121131..c8a186ba4ce 100644 --- a/app/workers/stuck_import_jobs_worker.rb +++ b/app/workers/stuck_import_jobs_worker.rb @@ -63,6 +63,6 @@ class StuckImportJobsWorker # rubocop: enable CodeReuse/ActiveRecord def error_message - "Import timed out. Import took longer than #{IMPORT_JOBS_EXPIRATION} seconds" + _("Import timed out. Import took longer than %{import_jobs_expiration} seconds") % { import_jobs_expiration: IMPORT_JOBS_EXPIRATION } end end diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index 9ce51662969..e8494ffa002 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -6,10 +6,11 @@ class UpdateHeadPipelineForMergeRequestWorker queue_namespace :pipeline_processing - # rubocop: disable CodeReuse/ActiveRecord def perform(merge_request_id) merge_request = MergeRequest.find(merge_request_id) - pipeline = Ci::Pipeline.where(project: merge_request.source_project, ref: merge_request.source_branch).last + + sha = merge_request.diff_head_sha + pipeline = merge_request.all_pipelines(shas: sha).first return unless pipeline && pipeline.latest? @@ -21,7 +22,6 @@ class UpdateHeadPipelineForMergeRequestWorker merge_request.update_attribute(:head_pipeline_id, pipeline.id) end - # rubocop: enable CodeReuse/ActiveRecord def log_error_message_for(merge_request) Rails.logger.error( diff --git a/changelogs/unreleased/19376-post-bfg-cleanup.yml b/changelogs/unreleased/19376-post-bfg-cleanup.yml new file mode 100644 index 00000000000..fc1bcc30db9 --- /dev/null +++ b/changelogs/unreleased/19376-post-bfg-cleanup.yml @@ -0,0 +1,5 @@ +--- +title: Use BFG object maps to clean projects +merge_request: 23189 +author: +type: added diff --git a/changelogs/unreleased/1979-redesign-mr-widget-approvals-ce.yml b/changelogs/unreleased/1979-redesign-mr-widget-approvals-ce.yml new file mode 100644 index 00000000000..d05b6054b22 --- /dev/null +++ b/changelogs/unreleased/1979-redesign-mr-widget-approvals-ce.yml @@ -0,0 +1,5 @@ +--- +title: Redesign of MR header sections (CE) +merge_request: 23465 +author: +type: changed diff --git a/changelogs/unreleased/22548-reopen-error-message.yml b/changelogs/unreleased/22548-reopen-error-message.yml new file mode 100644 index 00000000000..79c20eccb12 --- /dev/null +++ b/changelogs/unreleased/22548-reopen-error-message.yml @@ -0,0 +1,6 @@ +--- +title: Show error message when attempting to reopen an MR and there is an open MR + for the same branch +merge_request: 16447 +author: Akos Gyimesi +type: fixed diff --git a/changelogs/unreleased/33705-merge-request-rebase-api.yml b/changelogs/unreleased/33705-merge-request-rebase-api.yml new file mode 100644 index 00000000000..322fe31ce87 --- /dev/null +++ b/changelogs/unreleased/33705-merge-request-rebase-api.yml @@ -0,0 +1,5 @@ +--- +title: Add a rebase API endpoint for merge requests +merge_request: 23296 +author: +type: added diff --git a/changelogs/unreleased/34758-deployment-cluster.yml b/changelogs/unreleased/34758-deployment-cluster.yml new file mode 100644 index 00000000000..06374098343 --- /dev/null +++ b/changelogs/unreleased/34758-deployment-cluster.yml @@ -0,0 +1,5 @@ +--- +title: Use group clusters when deploying (DeploymentPlatform) +merge_request: 22308 +author: +type: changed diff --git a/changelogs/unreleased/38495-calendar-activities-in-timezone.yml b/changelogs/unreleased/38495-calendar-activities-in-timezone.yml new file mode 100644 index 00000000000..778d637609c --- /dev/null +++ b/changelogs/unreleased/38495-calendar-activities-in-timezone.yml @@ -0,0 +1,5 @@ +--- +title: Show user contributions in correct timezone within user profile +merge_request: 23419 +author: +type: changed diff --git a/changelogs/unreleased/39849_controller_sorts.yml b/changelogs/unreleased/39849_controller_sorts.yml new file mode 100644 index 00000000000..5fad0cb4ede --- /dev/null +++ b/changelogs/unreleased/39849_controller_sorts.yml @@ -0,0 +1,5 @@ +--- +title: Allow sorting issues and MRs in reverse order +merge_request: 21438 +author: +type: changed diff --git a/changelogs/unreleased/40385-prohibit_impersonation.yml b/changelogs/unreleased/40385-prohibit_impersonation.yml new file mode 100644 index 00000000000..dd061b17939 --- /dev/null +++ b/changelogs/unreleased/40385-prohibit_impersonation.yml @@ -0,0 +1,5 @@ +--- +title: Add config to prohibit impersonation +merge_request: 23338 +author: +type: added diff --git a/changelogs/unreleased/48496-merge-request-refactor-does-not-highlight-selected-line.yml b/changelogs/unreleased/48496-merge-request-refactor-does-not-highlight-selected-line.yml new file mode 100644 index 00000000000..cfc74bef638 --- /dev/null +++ b/changelogs/unreleased/48496-merge-request-refactor-does-not-highlight-selected-line.yml @@ -0,0 +1,5 @@ +--- +title: When user clicks linenumber in MR changes, highlight that line +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/48889-populate-merge_commit_sha.yml b/changelogs/unreleased/48889-populate-merge_commit_sha.yml new file mode 100644 index 00000000000..0e25d8ecfb0 --- /dev/null +++ b/changelogs/unreleased/48889-populate-merge_commit_sha.yml @@ -0,0 +1,6 @@ +--- +title: Fix "merged with [commit]" info for merge requests being merged automatically + by other actions +merge_request: 22794 +author: +type: fixed diff --git a/changelogs/unreleased/50264-add-border-around-the-repository-file-tree.yml b/changelogs/unreleased/50264-add-border-around-the-repository-file-tree.yml new file mode 100644 index 00000000000..6315c3e7f36 --- /dev/null +++ b/changelogs/unreleased/50264-add-border-around-the-repository-file-tree.yml @@ -0,0 +1,5 @@ +--- +title: Resolve Add border around the repository file tree +merge_request: 23018 +author: +type: changed diff --git a/changelogs/unreleased/50341-cleanup-useless-project-import-attributes.yml b/changelogs/unreleased/50341-cleanup-useless-project-import-attributes.yml new file mode 100644 index 00000000000..3893f14e15c --- /dev/null +++ b/changelogs/unreleased/50341-cleanup-useless-project-import-attributes.yml @@ -0,0 +1,6 @@ +--- +title: Removes all the irrelevant code and columns that were migrated from the Project + table over to the ProjectImportState table +merge_request: 21497 +author: +type: performance diff --git a/changelogs/unreleased/50626-searching-users-by-the-admin-panel-wipes-query-when-using-sort.yml b/changelogs/unreleased/50626-searching-users-by-the-admin-panel-wipes-query-when-using-sort.yml new file mode 100644 index 00000000000..c3251fea54d --- /dev/null +++ b/changelogs/unreleased/50626-searching-users-by-the-admin-panel-wipes-query-when-using-sort.yml @@ -0,0 +1,5 @@ +--- +title: Allow search and sort users at same time on admin users page +merge_request: 23439 +author: +type: fixed diff --git a/changelogs/unreleased/50839-webide-mr-dropdown-filter.yml b/changelogs/unreleased/50839-webide-mr-dropdown-filter.yml new file mode 100644 index 00000000000..1c6c8747197 --- /dev/null +++ b/changelogs/unreleased/50839-webide-mr-dropdown-filter.yml @@ -0,0 +1,5 @@ +--- +title: Scope default MR search in WebIDE dropdown to current project +merge_request: 23400 +author: +type: changed diff --git a/changelogs/unreleased/51029-status-emoji-currently-replaces-avatar-on-mobile.yml b/changelogs/unreleased/51029-status-emoji-currently-replaces-avatar-on-mobile.yml new file mode 100644 index 00000000000..dc11ede5c8d --- /dev/null +++ b/changelogs/unreleased/51029-status-emoji-currently-replaces-avatar-on-mobile.yml @@ -0,0 +1,5 @@ +--- +title: Resolve status emoji being replaced by avatar on mobile +merge_request: 23408 +author: +type: other diff --git a/changelogs/unreleased/51061-readme-url-n-1-rpc-call-resolved.yml b/changelogs/unreleased/51061-readme-url-n-1-rpc-call-resolved.yml new file mode 100644 index 00000000000..86f91fcb427 --- /dev/null +++ b/changelogs/unreleased/51061-readme-url-n-1-rpc-call-resolved.yml @@ -0,0 +1,5 @@ +--- +title: Improves performance of Project#readme_url by caching the README path +merge_request: 23357 +author: +type: performance diff --git a/changelogs/unreleased/51083-fix-move-api.yml b/changelogs/unreleased/51083-fix-move-api.yml new file mode 100644 index 00000000000..8838f6f267e --- /dev/null +++ b/changelogs/unreleased/51083-fix-move-api.yml @@ -0,0 +1,5 @@ +--- +title: 'Commits API: Preserve file content in move operations if unspecified' +merge_request: 23387 +author: +type: fixed diff --git a/changelogs/unreleased/51101-can-add-an-existing-group-member-into-a-group-project-with-new-permissions-but-permissions-are-not-overridde.yml b/changelogs/unreleased/51101-can-add-an-existing-group-member-into-a-group-project-with-new-permissions-but-permissions-are-not-overridde.yml new file mode 100644 index 00000000000..96f33a72cc5 --- /dev/null +++ b/changelogs/unreleased/51101-can-add-an-existing-group-member-into-a-group-project-with-new-permissions-but-permissions-are-not-overridde.yml @@ -0,0 +1,5 @@ +--- +title: Restrict member access level to be higher than that of any parent group +merge_request: 23226 +author: +type: fixed diff --git a/changelogs/unreleased/51138-54026-breadcrumb-subgroups-ellipsis.yml b/changelogs/unreleased/51138-54026-breadcrumb-subgroups-ellipsis.yml new file mode 100644 index 00000000000..f695d5aeff8 --- /dev/null +++ b/changelogs/unreleased/51138-54026-breadcrumb-subgroups-ellipsis.yml @@ -0,0 +1,5 @@ +--- +title: "Make auto-generated icons for subgroups in the breadcrumb dropdown display as a circle" +merge_request: 23062 +author: Thomas Pathier +type: fix
\ No newline at end of file diff --git a/changelogs/unreleased/51243-further-improvements-to-project-overview-ui.yml b/changelogs/unreleased/51243-further-improvements-to-project-overview-ui.yml new file mode 100644 index 00000000000..ddb5eaa89d0 --- /dev/null +++ b/changelogs/unreleased/51243-further-improvements-to-project-overview-ui.yml @@ -0,0 +1,5 @@ +--- +title: Design improvements to project overview page +merge_request: 22196 +author: +type: changed diff --git a/changelogs/unreleased/52285-omniauth-jwt-ppk-support.yml b/changelogs/unreleased/52285-omniauth-jwt-ppk-support.yml new file mode 100644 index 00000000000..3ef564238c5 --- /dev/null +++ b/changelogs/unreleased/52285-omniauth-jwt-ppk-support.yml @@ -0,0 +1,5 @@ +--- +title: Support RSA and ECDSA algorithms in Omniauth JWT provider +merge_request: 23411 +author: Michael Tsyganov +type: fixed diff --git a/changelogs/unreleased/52370-filter-by-none-any-for-labels-in-issues-mrs-boards.yml b/changelogs/unreleased/52370-filter-by-none-any-for-labels-in-issues-mrs-boards.yml new file mode 100644 index 00000000000..9e1ee3ede5e --- /dev/null +++ b/changelogs/unreleased/52370-filter-by-none-any-for-labels-in-issues-mrs-boards.yml @@ -0,0 +1,5 @@ +--- +title: Adds Any option to label filters +merge_request: 23111 +author: Jacopo Beschi @jacopo-beschi +type: added diff --git a/changelogs/unreleased/53400-unstar-icon-button-is-misaligned.yml b/changelogs/unreleased/53400-unstar-icon-button-is-misaligned.yml new file mode 100644 index 00000000000..b393795f491 --- /dev/null +++ b/changelogs/unreleased/53400-unstar-icon-button-is-misaligned.yml @@ -0,0 +1,5 @@ +--- +title: 'Fix: Unstar icon button is misaligned' +merge_request: 23444 +author: +type: fixed diff --git a/changelogs/unreleased/53659-use-padded-key-for-gcm-ciphers.yml b/changelogs/unreleased/53659-use-padded-key-for-gcm-ciphers.yml new file mode 100644 index 00000000000..fe9ac7b3dc7 --- /dev/null +++ b/changelogs/unreleased/53659-use-padded-key-for-gcm-ciphers.yml @@ -0,0 +1,5 @@ +--- +title: Fix web hook functionality when the database encryption key is too short +merge_request: 23573 +author: +type: fixed diff --git a/changelogs/unreleased/53763-fix-encrypt-columns-data-loss.yml b/changelogs/unreleased/53763-fix-encrypt-columns-data-loss.yml deleted file mode 100644 index 44362a8622e..00000000000 --- a/changelogs/unreleased/53763-fix-encrypt-columns-data-loss.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Correctly handle data-loss scenarios when encrypting columns -merge_request: 23306 -author: -type: fixed diff --git a/changelogs/unreleased/53778-remove-site-statistics.yml b/changelogs/unreleased/53778-remove-site-statistics.yml deleted file mode 100644 index fe006e43671..00000000000 --- a/changelogs/unreleased/53778-remove-site-statistics.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Removed Site Statistics optimization as it was causing problems -merge_request: 23314 -author: -type: removed diff --git a/changelogs/unreleased/53994-add-missing-ci_builds-partial-indices.yml b/changelogs/unreleased/53994-add-missing-ci_builds-partial-indices.yml new file mode 100644 index 00000000000..4673ba38bae --- /dev/null +++ b/changelogs/unreleased/53994-add-missing-ci_builds-partial-indices.yml @@ -0,0 +1,5 @@ +--- +title: Add partial index for ci_builds on project_id and status +merge_request: 23268 +author: +type: performance diff --git a/changelogs/unreleased/54048-Line-numbers-are-misaligned-in-file-blame-view.yml b/changelogs/unreleased/54048-Line-numbers-are-misaligned-in-file-blame-view.yml new file mode 100644 index 00000000000..8ceac4ec869 --- /dev/null +++ b/changelogs/unreleased/54048-Line-numbers-are-misaligned-in-file-blame-view.yml @@ -0,0 +1,5 @@ +--- +title: Fix line height of numbers in file blame view +merge_request: 23090 +author: Johann Hubert Sonntagbauer +type: fixed diff --git a/changelogs/unreleased/54218-fix-mergeUrlParams.yml b/changelogs/unreleased/54218-fix-mergeUrlParams.yml new file mode 100644 index 00000000000..dae06b66e8e --- /dev/null +++ b/changelogs/unreleased/54218-fix-mergeUrlParams.yml @@ -0,0 +1,5 @@ +--- +title: "Fix mergeUrlParams with fragment URL" +merge_request: 54218 +author: Thomas Holder +type: fixed diff --git a/changelogs/unreleased/54282-tooltip-stuck.yml b/changelogs/unreleased/54282-tooltip-stuck.yml deleted file mode 100644 index 655870499a0..00000000000 --- a/changelogs/unreleased/54282-tooltip-stuck.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fixes stuck tooltip on stop env button -merge_request: 23244 -author: -type: fixed diff --git a/changelogs/unreleased/54336-include-tags-into-pipeline-detail-view.yml b/changelogs/unreleased/54336-include-tags-into-pipeline-detail-view.yml new file mode 100644 index 00000000000..11f941ab9bb --- /dev/null +++ b/changelogs/unreleased/54336-include-tags-into-pipeline-detail-view.yml @@ -0,0 +1,5 @@ +--- +title: Merge request pipeline tag, and adds tags to pipeline view +merge_request: 23364 +author: +type: added diff --git a/changelogs/unreleased/54571-runner-tags.yml b/changelogs/unreleased/54571-runner-tags.yml new file mode 100644 index 00000000000..1bb19d22e9c --- /dev/null +++ b/changelogs/unreleased/54571-runner-tags.yml @@ -0,0 +1,5 @@ +--- +title: Adds margins between tags when a job is stuck +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/54648-fix-order-by-dropdown-tablet-screens.yml b/changelogs/unreleased/54648-fix-order-by-dropdown-tablet-screens.yml new file mode 100644 index 00000000000..671d1590991 --- /dev/null +++ b/changelogs/unreleased/54648-fix-order-by-dropdown-tablet-screens.yml @@ -0,0 +1,5 @@ +--- +title: Fix Order By dropdown menu styling in tablet and mobile screens +merge_request: 23446 +author: +type: fixed diff --git a/changelogs/unreleased/54826-use-read_repository-scope-on-read-only-files-endpoints.yml b/changelogs/unreleased/54826-use-read_repository-scope-on-read-only-files-endpoints.yml new file mode 100644 index 00000000000..ef8e93fca43 --- /dev/null +++ b/changelogs/unreleased/54826-use-read_repository-scope-on-read-only-files-endpoints.yml @@ -0,0 +1,5 @@ +--- +title: Use read_repository scope on read-only files API +merge_request: 23534 +author: +type: fixed diff --git a/changelogs/unreleased/54857-fix-templates-path-traversal.yml b/changelogs/unreleased/54857-fix-templates-path-traversal.yml new file mode 100644 index 00000000000..0da02432c60 --- /dev/null +++ b/changelogs/unreleased/54857-fix-templates-path-traversal.yml @@ -0,0 +1,5 @@ +--- +title: Prevent a path traversal attack on global file templates +merge_request: +author: +type: security diff --git a/changelogs/unreleased/54975-fix-web-hooks-rake-task.yml b/changelogs/unreleased/54975-fix-web-hooks-rake-task.yml new file mode 100644 index 00000000000..107a93e5b12 --- /dev/null +++ b/changelogs/unreleased/54975-fix-web-hooks-rake-task.yml @@ -0,0 +1,5 @@ +--- +title: Fix gitlab:web_hook tasks +merge_request: 23635 +author: +type: fixed diff --git a/changelogs/unreleased/_acet-fix-flash-styling.yml b/changelogs/unreleased/_acet-fix-flash-styling.yml new file mode 100644 index 00000000000..57354c04899 --- /dev/null +++ b/changelogs/unreleased/_acet-fix-flash-styling.yml @@ -0,0 +1,5 @@ +--- +title: Fix flash notice styling for fluid layout +merge_request: 23382 +author: +type: fixed diff --git a/changelogs/unreleased/ab-approximate-counts.yml b/changelogs/unreleased/ab-approximate-counts.yml new file mode 100644 index 00000000000..8a67239d031 --- /dev/null +++ b/changelogs/unreleased/ab-approximate-counts.yml @@ -0,0 +1,5 @@ +--- +title: Approximate counting strategy with TABLESAMPLE. +merge_request: 22650 +author: +type: performance diff --git a/changelogs/unreleased/auto-devops-support-for-group-security-dashboard.yml b/changelogs/unreleased/auto-devops-support-for-group-security-dashboard.yml deleted file mode 100644 index 7fb11f24902..00000000000 --- a/changelogs/unreleased/auto-devops-support-for-group-security-dashboard.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Auto DevOps support for Group Security Dashboard -merge_request: 23165 -author: -type: fixed diff --git a/changelogs/unreleased/bump_gpgme_gem.yml b/changelogs/unreleased/bump_gpgme_gem.yml new file mode 100644 index 00000000000..4c0067cb824 --- /dev/null +++ b/changelogs/unreleased/bump_gpgme_gem.yml @@ -0,0 +1,5 @@ +--- +title: Bump gpgme gem version from 2.0.13 to 2.0.18 +merge_request: +author: asaparov +type: other diff --git a/changelogs/unreleased/ce-52811-fix_namespaces_api_routing.yml b/changelogs/unreleased/ce-52811-fix_namespaces_api_routing.yml new file mode 100644 index 00000000000..b5fd99c304f --- /dev/null +++ b/changelogs/unreleased/ce-52811-fix_namespaces_api_routing.yml @@ -0,0 +1,5 @@ +--- +title: Fix API::Namespaces routing to accept namepaces with dots +merge_request: 22912 +author: +type: fixed diff --git a/changelogs/unreleased/ce-53347_fix_impersonation_tokens.yml b/changelogs/unreleased/ce-53347_fix_impersonation_tokens.yml deleted file mode 100644 index 6cc743d6f3a..00000000000 --- a/changelogs/unreleased/ce-53347_fix_impersonation_tokens.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Display impersonation token value only after creation -merge_request: 22916 -author: -type: fixed diff --git a/changelogs/unreleased/check-if-fetched-data-does-is-complete.yml b/changelogs/unreleased/check-if-fetched-data-does-is-complete.yml new file mode 100644 index 00000000000..31c131045b9 --- /dev/null +++ b/changelogs/unreleased/check-if-fetched-data-does-is-complete.yml @@ -0,0 +1,5 @@ +--- +title: Validate chunk size when persist +merge_request: 23341 +author: +type: fixed diff --git a/changelogs/unreleased/define-default-value-for-only-except-keys.yml b/changelogs/unreleased/define-default-value-for-only-except-keys.yml new file mode 100644 index 00000000000..3e5ecdcf51e --- /dev/null +++ b/changelogs/unreleased/define-default-value-for-only-except-keys.yml @@ -0,0 +1,5 @@ +--- +title: Define the default value for only/except policies +merge_request: 23531 +author: +type: changed diff --git a/changelogs/unreleased/deprecated-instance-find.yml b/changelogs/unreleased/deprecated-instance-find.yml new file mode 100644 index 00000000000..d2ba821e124 --- /dev/null +++ b/changelogs/unreleased/deprecated-instance-find.yml @@ -0,0 +1,5 @@ +--- +title: 'Fix deprecation: You are passing an instance of ActiveRecord::Base to' +merge_request: 23369 +author: Jasper Maes +type: other diff --git a/changelogs/unreleased/diff-expand-commit-file.yml b/changelogs/unreleased/diff-expand-commit-file.yml new file mode 100644 index 00000000000..8ca784d75c1 --- /dev/null +++ b/changelogs/unreleased/diff-expand-commit-file.yml @@ -0,0 +1,5 @@ +--- +title: Fixed diff files expanding not loading commit content +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/diff-fix-expanding.yml b/changelogs/unreleased/diff-fix-expanding.yml new file mode 100644 index 00000000000..8ba7f87addc --- /dev/null +++ b/changelogs/unreleased/diff-fix-expanding.yml @@ -0,0 +1,5 @@ +--- +title: Fixed multiple diff line discussions not expanding +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/dm-batch-loader-sidekiq.yml b/changelogs/unreleased/dm-batch-loader-sidekiq.yml deleted file mode 100644 index 87936dc2603..00000000000 --- a/changelogs/unreleased/dm-batch-loader-sidekiq.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Clear BatchLoader context between Sidekiq jobs -merge_request: 23308 -author: -type: fixed diff --git a/changelogs/unreleased/dm-remove-prune-web-hook-logs-worker.yml b/changelogs/unreleased/dm-remove-prune-web-hook-logs-worker.yml new file mode 100644 index 00000000000..fb0c508400c --- /dev/null +++ b/changelogs/unreleased/dm-remove-prune-web-hook-logs-worker.yml @@ -0,0 +1,5 @@ +--- +title: Remove old webhook logs after 90 days, as documented, instead of after 2 +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/document-raw-snippet-api.yml b/changelogs/unreleased/document-raw-snippet-api.yml new file mode 100644 index 00000000000..3b8818cea5c --- /dev/null +++ b/changelogs/unreleased/document-raw-snippet-api.yml @@ -0,0 +1,5 @@ +--- +title: Fix lack of documentation on how to fetch a snippet's content using API +merge_request: 23448 +author: Colin Leroy +type: other diff --git a/changelogs/unreleased/expose-mr-pipeline-variables.yml b/changelogs/unreleased/expose-mr-pipeline-variables.yml new file mode 100644 index 00000000000..b77b9a69d5c --- /dev/null +++ b/changelogs/unreleased/expose-mr-pipeline-variables.yml @@ -0,0 +1,5 @@ +--- +title: Expose merge request pipeline variables +merge_request: 23398 +author: +type: changed diff --git a/changelogs/unreleased/fix-gb-encrypt-ci-build-token.yml b/changelogs/unreleased/fix-gb-encrypt-ci-build-token.yml new file mode 100644 index 00000000000..04fc88bc3d3 --- /dev/null +++ b/changelogs/unreleased/fix-gb-encrypt-ci-build-token.yml @@ -0,0 +1,5 @@ +--- +title: Encrypt CI/CD builds authentication tokens +merge_request: 23436 +author: +type: security diff --git a/changelogs/unreleased/fix-gb-encrypt-runners-tokens.yml b/changelogs/unreleased/fix-gb-encrypt-runners-tokens.yml new file mode 100644 index 00000000000..4ce4f96c1dd --- /dev/null +++ b/changelogs/unreleased/fix-gb-encrypt-runners-tokens.yml @@ -0,0 +1,5 @@ +--- +title: Encrypt runners tokens +merge_request: 23412 +author: +type: security diff --git a/changelogs/unreleased/fix-gb-improve-timeout-inputs-help-sections.yml b/changelogs/unreleased/fix-gb-improve-timeout-inputs-help-sections.yml new file mode 100644 index 00000000000..52b431edf2c --- /dev/null +++ b/changelogs/unreleased/fix-gb-improve-timeout-inputs-help-sections.yml @@ -0,0 +1,5 @@ +--- +title: Improve help and validation sections of maximum build timeout inputs +merge_request: 23586 +author: +type: fixed diff --git a/changelogs/unreleased/fix-mr-widget-unrelated-deployment-status.yml b/changelogs/unreleased/fix-mr-widget-unrelated-deployment-status.yml new file mode 100644 index 00000000000..ab926fbd43b --- /dev/null +++ b/changelogs/unreleased/fix-mr-widget-unrelated-deployment-status.yml @@ -0,0 +1,5 @@ +--- +title: Fix unrelated deployment status in MR widget +merge_request: 23175 +author: +type: fixed diff --git a/changelogs/unreleased/fix-multiple-comments-shade-overlap.yml b/changelogs/unreleased/fix-multiple-comments-shade-overlap.yml new file mode 100644 index 00000000000..20005ba355e --- /dev/null +++ b/changelogs/unreleased/fix-multiple-comments-shade-overlap.yml @@ -0,0 +1,5 @@ +--- +title: Fix multiple commits shade overlapping vertical discussion line +merge_request: 23515 +author: +type: fixed diff --git a/changelogs/unreleased/fix-not-render-emoji.yml b/changelogs/unreleased/fix-not-render-emoji.yml deleted file mode 100644 index 857b97004f0..00000000000 --- a/changelogs/unreleased/fix-not-render-emoji.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix not render emoji in filter dropdown -merge_request: 23112 -author: Hiroyuki Sato -type: fixed diff --git a/changelogs/unreleased/fj-clean-content-headers.yml b/changelogs/unreleased/fj-clean-content-headers.yml new file mode 100644 index 00000000000..59e25ca6578 --- /dev/null +++ b/changelogs/unreleased/fj-clean-content-headers.yml @@ -0,0 +1,5 @@ +--- +title: Added feature flag to signal content headers detection by Workhorse +merge_request: 22667 +author: +type: added diff --git a/changelogs/unreleased/gt-add-top-padding-for-nested-environment-items-loading-icon.yml b/changelogs/unreleased/gt-add-top-padding-for-nested-environment-items-loading-icon.yml new file mode 100644 index 00000000000..606314b5780 --- /dev/null +++ b/changelogs/unreleased/gt-add-top-padding-for-nested-environment-items-loading-icon.yml @@ -0,0 +1,5 @@ +--- +title: Add top padding for nested environment items loading icon +merge_request: 23580 +author: George Tsiolis +type: fixed diff --git a/changelogs/unreleased/gt-change-container-width-for-project-import.yml b/changelogs/unreleased/gt-change-container-width-for-project-import.yml new file mode 100644 index 00000000000..ec2beb15912 --- /dev/null +++ b/changelogs/unreleased/gt-change-container-width-for-project-import.yml @@ -0,0 +1,5 @@ +--- +title: Change container width for project import +merge_request: 23318 +author: George Tsiolis +type: fixed diff --git a/changelogs/unreleased/gt-externalize-app-views-invites.yml b/changelogs/unreleased/gt-externalize-app-views-invites.yml new file mode 100644 index 00000000000..b5a22177f9b --- /dev/null +++ b/changelogs/unreleased/gt-externalize-app-views-invites.yml @@ -0,0 +1,5 @@ +--- +title: Externalize strings from `/app/views/invites` +merge_request: 23205 +author: Tao Wang +type: other diff --git a/changelogs/unreleased/gt-externalize-app-views-snippets.yml b/changelogs/unreleased/gt-externalize-app-views-snippets.yml new file mode 100644 index 00000000000..633aa9f2534 --- /dev/null +++ b/changelogs/unreleased/gt-externalize-app-views-snippets.yml @@ -0,0 +1,5 @@ +--- +title: Externalize strings from `/app/views/snippets` +merge_request: 23351 +author: Tao Wang +type: other diff --git a/changelogs/unreleased/gt-show-primary-button-when-all-labels-are-prioritized.yml b/changelogs/unreleased/gt-show-primary-button-when-all-labels-are-prioritized.yml new file mode 100644 index 00000000000..eed31950a76 --- /dev/null +++ b/changelogs/unreleased/gt-show-primary-button-when-all-labels-are-prioritized.yml @@ -0,0 +1,5 @@ +--- +title: Show primary button when all labels are prioritized +merge_request: 23648 +author: George Tsiolis +type: other diff --git a/changelogs/unreleased/ignore-failed-pipeline-creation-on-pipeline-schedule.yml b/changelogs/unreleased/ignore-failed-pipeline-creation-on-pipeline-schedule.yml new file mode 100644 index 00000000000..90f47aa12db --- /dev/null +++ b/changelogs/unreleased/ignore-failed-pipeline-creation-on-pipeline-schedule.yml @@ -0,0 +1,5 @@ +--- +title: Remove auto deactivation when failed to create a pipeline via pipeline schedules +merge_request: 22243 +author: +type: changed diff --git a/changelogs/unreleased/improve_auto_devops_migration_debug.yml b/changelogs/unreleased/improve_auto_devops_migration_debug.yml new file mode 100644 index 00000000000..96a78808361 --- /dev/null +++ b/changelogs/unreleased/improve_auto_devops_migration_debug.yml @@ -0,0 +1,6 @@ +--- +title: 'Auto DevOps: Add echo for each branch of the deploy() function where we run + helm upgrade' +merge_request: 23499 +author: +type: changed diff --git a/changelogs/unreleased/include-new-link-in-breadcrumb.yml b/changelogs/unreleased/include-new-link-in-breadcrumb.yml new file mode 100644 index 00000000000..68c808d66d7 --- /dev/null +++ b/changelogs/unreleased/include-new-link-in-breadcrumb.yml @@ -0,0 +1,5 @@ +--- +title: Include new link in breadcrumb for issues, merge requests, milestones, and labels +merge_request: 18515 +author: George Tsiolis +type: changed diff --git a/changelogs/unreleased/jivl-add-empty-state-graphs-null-values.yml b/changelogs/unreleased/jivl-add-empty-state-graphs-null-values.yml new file mode 100644 index 00000000000..d21254b16d0 --- /dev/null +++ b/changelogs/unreleased/jivl-add-empty-state-graphs-null-values.yml @@ -0,0 +1,5 @@ +--- +title: Add empty state for graphs with no values +merge_request: 22630 +author: +type: fixed diff --git a/changelogs/unreleased/jupyter-tls.yml b/changelogs/unreleased/jupyter-tls.yml new file mode 100644 index 00000000000..4111edd34ff --- /dev/null +++ b/changelogs/unreleased/jupyter-tls.yml @@ -0,0 +1,5 @@ +--- +title: "#52753: HTTPS for JupyterHub installation" +merge_request: 23479 +author: Amit Rathi +type: added diff --git a/changelogs/unreleased/legacy_fallback_for_project_clusters_only.yml b/changelogs/unreleased/legacy_fallback_for_project_clusters_only.yml new file mode 100644 index 00000000000..c8e959176d0 --- /dev/null +++ b/changelogs/unreleased/legacy_fallback_for_project_clusters_only.yml @@ -0,0 +1,5 @@ +--- +title: Fallback to admin KUBE_TOKEN for project clusters only +merge_request: 23527 +author: +type: other diff --git a/changelogs/unreleased/mg-fix-knative-application-row.yml b/changelogs/unreleased/mg-fix-knative-application-row.yml new file mode 100644 index 00000000000..95142d380a4 --- /dev/null +++ b/changelogs/unreleased/mg-fix-knative-application-row.yml @@ -0,0 +1,5 @@ +--- +title: Hide Knative from group cluster applications until supported +merge_request: 23577 +author: +type: fixed diff --git a/changelogs/unreleased/move-group-issues-search-cte-up-the-chain.yml b/changelogs/unreleased/move-group-issues-search-cte-up-the-chain.yml new file mode 100644 index 00000000000..0269e7b6196 --- /dev/null +++ b/changelogs/unreleased/move-group-issues-search-cte-up-the-chain.yml @@ -0,0 +1,5 @@ +--- +title: Fix error when searching for group issues with priority or popularity sort +merge_request: 23445 +author: +type: fixed diff --git a/changelogs/unreleased/mr-pipelines-2.yml b/changelogs/unreleased/mr-pipelines-2.yml new file mode 100644 index 00000000000..683c626c3ce --- /dev/null +++ b/changelogs/unreleased/mr-pipelines-2.yml @@ -0,0 +1,5 @@ +--- +title: Merge request pipelines +merge_request: 23217 +author: +type: added diff --git a/changelogs/unreleased/multiple-diff-line-discussions-fix.yml b/changelogs/unreleased/multiple-diff-line-discussions-fix.yml new file mode 100644 index 00000000000..870a8ab3815 --- /dev/null +++ b/changelogs/unreleased/multiple-diff-line-discussions-fix.yml @@ -0,0 +1,5 @@ +--- +title: Fixed duplicate discussions getting added to diff lines +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/non-webkit-scrollbar-fixing.yml b/changelogs/unreleased/non-webkit-scrollbar-fixing.yml new file mode 100644 index 00000000000..526a9f25486 --- /dev/null +++ b/changelogs/unreleased/non-webkit-scrollbar-fixing.yml @@ -0,0 +1,5 @@ +--- +title: Fix horizontal scrollbar overlapping on horizontal scrolling-tabs +merge_request: 23167 +author: Harry Kiselev +type: other diff --git a/changelogs/unreleased/order-of-notification-settings.yml b/changelogs/unreleased/order-of-notification-settings.yml new file mode 100644 index 00000000000..0f0243bcb40 --- /dev/null +++ b/changelogs/unreleased/order-of-notification-settings.yml @@ -0,0 +1,5 @@ +--- +title: reorder notification settings by noisy-ness +merge_request: +author: C.J. Jameson +type: changed diff --git a/changelogs/unreleased/osw-fix-grouping-by-file-path.yml b/changelogs/unreleased/osw-fix-grouping-by-file-path.yml new file mode 100644 index 00000000000..dff3116e7c6 --- /dev/null +++ b/changelogs/unreleased/osw-fix-grouping-by-file-path.yml @@ -0,0 +1,5 @@ +--- +title: Avoid 500's when serializing legacy diff notes +merge_request: 23544 +author: +type: fixed diff --git a/changelogs/unreleased/osw-remove-unnused-data-from-diff-discussions.yml b/changelogs/unreleased/osw-remove-unnused-data-from-diff-discussions.yml new file mode 100644 index 00000000000..58d9a19d038 --- /dev/null +++ b/changelogs/unreleased/osw-remove-unnused-data-from-diff-discussions.yml @@ -0,0 +1,5 @@ +--- +title: Remove unused data from discussions endpoint +merge_request: 23570 +author: +type: performance diff --git a/changelogs/unreleased/remove-blob-search-limit.yml b/changelogs/unreleased/remove-blob-search-limit.yml new file mode 100644 index 00000000000..5bad3a83dbb --- /dev/null +++ b/changelogs/unreleased/remove-blob-search-limit.yml @@ -0,0 +1,5 @@ +--- +title: Remove limit of 100 when searching repository code. +merge_request: 8671 +author: +type: fixed diff --git a/changelogs/unreleased/remove-deployment-status-hack-from-backend.yml b/changelogs/unreleased/remove-deployment-status-hack-from-backend.yml new file mode 100644 index 00000000000..2348bfab7d9 --- /dev/null +++ b/changelogs/unreleased/remove-deployment-status-hack-from-backend.yml @@ -0,0 +1,5 @@ +--- +title: Return real deployment status to frontend +merge_request: 23270 +author: +type: fixed diff --git a/changelogs/unreleased/render-text-deprecated.yml b/changelogs/unreleased/render-text-deprecated.yml new file mode 100644 index 00000000000..7dbbd13bcef --- /dev/null +++ b/changelogs/unreleased/render-text-deprecated.yml @@ -0,0 +1,6 @@ +--- +title: 'Fix deprecation: render :text is deprecated because it does not actually render + a text/plain response' +merge_request: 23425 +author: Jasper Maes +type: other diff --git a/changelogs/unreleased/retryable_create_or_update_kubernetes_namespace.yml b/changelogs/unreleased/retryable_create_or_update_kubernetes_namespace.yml new file mode 100644 index 00000000000..607f2709f90 --- /dev/null +++ b/changelogs/unreleased/retryable_create_or_update_kubernetes_namespace.yml @@ -0,0 +1,6 @@ +--- +title: Updates service to update Kubernetes project namespaces and restricted service + account if present +merge_request: 23525 +author: +type: changed diff --git a/changelogs/unreleased/security-182-update-workhorse.yml b/changelogs/unreleased/security-182-update-workhorse.yml new file mode 100644 index 00000000000..76850901b68 --- /dev/null +++ b/changelogs/unreleased/security-182-update-workhorse.yml @@ -0,0 +1,5 @@ +--- +title: Redact sensitive information on gitlab-workhorse log +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-2736-prometheus-ssrf.yml b/changelogs/unreleased/security-2736-prometheus-ssrf.yml new file mode 100644 index 00000000000..9d0dda8a75f --- /dev/null +++ b/changelogs/unreleased/security-2736-prometheus-ssrf.yml @@ -0,0 +1,5 @@ +--- +title: Do not follow redirects in Prometheus service when making http requests to the configured api url +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-bvl-exposure-in-commits-list.yml b/changelogs/unreleased/security-bvl-exposure-in-commits-list.yml new file mode 100644 index 00000000000..0361fb0c041 --- /dev/null +++ b/changelogs/unreleased/security-bvl-exposure-in-commits-list.yml @@ -0,0 +1,5 @@ +--- +title: Don't expose confidential information in commit message list +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-email-change-notification.yml b/changelogs/unreleased/security-email-change-notification.yml new file mode 100644 index 00000000000..45075ff20bb --- /dev/null +++ b/changelogs/unreleased/security-email-change-notification.yml @@ -0,0 +1,5 @@ +--- +title: Provide email notification when a user changes their email address +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-fix-pat-web-access.yml b/changelogs/unreleased/security-fix-pat-web-access.yml new file mode 100644 index 00000000000..62ffb908fe5 --- /dev/null +++ b/changelogs/unreleased/security-fix-pat-web-access.yml @@ -0,0 +1,5 @@ +--- +title: Restrict Personal Access Tokens to API scope on web requests +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-fix-uri-xss-applications.yml b/changelogs/unreleased/security-fix-uri-xss-applications.yml new file mode 100644 index 00000000000..0eaa1b1c4a3 --- /dev/null +++ b/changelogs/unreleased/security-fix-uri-xss-applications.yml @@ -0,0 +1,5 @@ +--- +title: Resolve reflected XSS in Ouath authorize window +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-fix-webhook-ssrf-ipv6.yml b/changelogs/unreleased/security-fix-webhook-ssrf-ipv6.yml new file mode 100644 index 00000000000..32c85a2a7da --- /dev/null +++ b/changelogs/unreleased/security-fix-webhook-ssrf-ipv6.yml @@ -0,0 +1,5 @@ +--- +title: Fix SSRF in project integrations +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-fj-crlf-injection.yml b/changelogs/unreleased/security-fj-crlf-injection.yml new file mode 100644 index 00000000000..861167b8a6e --- /dev/null +++ b/changelogs/unreleased/security-fj-crlf-injection.yml @@ -0,0 +1,5 @@ +--- +title: Fix CRLF vulnerability in Project hooks +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-guest-comments.yml b/changelogs/unreleased/security-guest-comments.yml new file mode 100644 index 00000000000..2c99512433b --- /dev/null +++ b/changelogs/unreleased/security-guest-comments.yml @@ -0,0 +1,5 @@ +--- +title: Fixed ability to comment on locked/confidential issues. +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-guest-comments_2.yml b/changelogs/unreleased/security-guest-comments_2.yml new file mode 100644 index 00000000000..be6f2d6a490 --- /dev/null +++ b/changelogs/unreleased/security-guest-comments_2.yml @@ -0,0 +1,5 @@ +--- +title: Fixed ability of guest users to edit/delete comments on locked or confidential issues. +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-issue_51301.yml b/changelogs/unreleased/security-issue_51301.yml new file mode 100644 index 00000000000..cf8ebb54b1c --- /dev/null +++ b/changelogs/unreleased/security-issue_51301.yml @@ -0,0 +1,5 @@ +--- +title: Fix milestone promotion authorization check +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-mermaid-xss.yml b/changelogs/unreleased/security-mermaid-xss.yml new file mode 100644 index 00000000000..bcf93ef37ff --- /dev/null +++ b/changelogs/unreleased/security-mermaid-xss.yml @@ -0,0 +1,5 @@ +--- +title: Configure mermaid to not render HTML content in diagrams +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-pages-toctou-race.yml b/changelogs/unreleased/security-pages-toctou-race.yml new file mode 100644 index 00000000000..1c055f6087f --- /dev/null +++ b/changelogs/unreleased/security-pages-toctou-race.yml @@ -0,0 +1,6 @@ +--- +title: Fix a possible symlink time of check to time of use race condition in GitLab + Pages +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-private-group.yml b/changelogs/unreleased/security-private-group.yml new file mode 100644 index 00000000000..dbb7794dfed --- /dev/null +++ b/changelogs/unreleased/security-private-group.yml @@ -0,0 +1,6 @@ +--- +title: Removed ability to see private group names when the group id is entered in + the url. +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-stored-xss-for-environments.yml b/changelogs/unreleased/security-stored-xss-for-environments.yml new file mode 100644 index 00000000000..5d78ca00942 --- /dev/null +++ b/changelogs/unreleased/security-stored-xss-for-environments.yml @@ -0,0 +1,5 @@ +--- +title: Fix stored XSS for Environments +merge_request: +author: +type: security diff --git a/changelogs/unreleased/security-xss-in-markdown-following-unrecognized-html-element.yml b/changelogs/unreleased/security-xss-in-markdown-following-unrecognized-html-element.yml new file mode 100644 index 00000000000..3bd8123a346 --- /dev/null +++ b/changelogs/unreleased/security-xss-in-markdown-following-unrecognized-html-element.yml @@ -0,0 +1,5 @@ +--- +title: Fix possible XSS attack in Markdown urls with spaces +merge_request: 2599 +author: +type: security diff --git a/changelogs/unreleased/set-kubeconfig-nil-when-token-nil.yml b/changelogs/unreleased/set-kubeconfig-nil-when-token-nil.yml new file mode 100644 index 00000000000..6eac2a0146c --- /dev/null +++ b/changelogs/unreleased/set-kubeconfig-nil-when-token-nil.yml @@ -0,0 +1,5 @@ +--- +title: Make KUBECONFIG nil if KUBE_TOKEN is nil +merge_request: 23414 +author: +type: fixed diff --git a/changelogs/unreleased/sh-disable-autocomplete-mirror-settings.yml b/changelogs/unreleased/sh-disable-autocomplete-mirror-settings.yml new file mode 100644 index 00000000000..e42906e88f2 --- /dev/null +++ b/changelogs/unreleased/sh-disable-autocomplete-mirror-settings.yml @@ -0,0 +1,5 @@ +--- +title: Disable password autocomplete in mirror form fill +merge_request: 23402 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-hash-filename-handling.yml b/changelogs/unreleased/sh-fix-hash-filename-handling.yml deleted file mode 100644 index cb32051a4ab..00000000000 --- a/changelogs/unreleased/sh-fix-hash-filename-handling.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix handling of filenames with hash characters in tree view -merge_request: 23368 -author: -type: fixed diff --git a/changelogs/unreleased/sh-fix-issue-38317.yml b/changelogs/unreleased/sh-fix-issue-38317.yml new file mode 100644 index 00000000000..13fcb5b8f96 --- /dev/null +++ b/changelogs/unreleased/sh-fix-issue-38317.yml @@ -0,0 +1,5 @@ +--- +title: Remove needless auto-capitalization on Wiki page titles +merge_request: 23288 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-mirrors-protected-branches.yml b/changelogs/unreleased/sh-fix-mirrors-protected-branches.yml new file mode 100644 index 00000000000..627de25650d --- /dev/null +++ b/changelogs/unreleased/sh-fix-mirrors-protected-branches.yml @@ -0,0 +1,5 @@ +--- +title: Fix "protected branches only" checkbox not set properly at init +merge_request: 23409 +author: +type: fixed diff --git a/changelogs/unreleased/sh-handle-invalid-gpg-sig.yml b/changelogs/unreleased/sh-handle-invalid-gpg-sig.yml new file mode 100644 index 00000000000..185e2547e16 --- /dev/null +++ b/changelogs/unreleased/sh-handle-invalid-gpg-sig.yml @@ -0,0 +1,5 @@ +--- +title: Gracefully handle unknown/invalid GPG keys +merge_request: 23492 +author: +type: fixed diff --git a/changelogs/unreleased/sh-truncate-with-periods.yml b/changelogs/unreleased/sh-truncate-with-periods.yml new file mode 100644 index 00000000000..b1c6b4f9cbd --- /dev/null +++ b/changelogs/unreleased/sh-truncate-with-periods.yml @@ -0,0 +1,5 @@ +--- +title: Truncate merge request titles with periods instead of ellipsis +merge_request: 23558 +author: +type: changed diff --git a/changelogs/unreleased/store-correlation-logs.yml b/changelogs/unreleased/store-correlation-logs.yml new file mode 100644 index 00000000000..d5f6c789a17 --- /dev/null +++ b/changelogs/unreleased/store-correlation-logs.yml @@ -0,0 +1,5 @@ +--- +title: Log and pass correlation-id between Unicorn, Sidekiq and Gitaly +merge_request: +author: +type: added diff --git a/changelogs/unreleased/tc-backfill-full-path-config.yml b/changelogs/unreleased/tc-backfill-full-path-config.yml new file mode 100644 index 00000000000..4f06284d0e3 --- /dev/null +++ b/changelogs/unreleased/tc-backfill-full-path-config.yml @@ -0,0 +1,5 @@ +--- +title: Migration to write fullpath in all repository configs +merge_request: 22322 +author: +type: other diff --git a/changelogs/unreleased/tc-repo-full-path-in-db.yml b/changelogs/unreleased/tc-repo-full-path-in-db.yml new file mode 100644 index 00000000000..ead8feabeb9 --- /dev/null +++ b/changelogs/unreleased/tc-repo-full-path-in-db.yml @@ -0,0 +1,5 @@ +--- +title: Add model and relation to store repo full path in database +merge_request: 23143 +author: +type: added diff --git a/changelogs/unreleased/triggermesh-phase2-serverless-list.yml b/changelogs/unreleased/triggermesh-phase2-serverless-list.yml new file mode 100644 index 00000000000..22e1a35dd90 --- /dev/null +++ b/changelogs/unreleased/triggermesh-phase2-serverless-list.yml @@ -0,0 +1,5 @@ +--- +title: Introduce Knative and Serverless Components +merge_request: 23174 +author: Chris Baumbauer +type: added diff --git a/changelogs/unreleased/unicorn-monkey-patch.yml b/changelogs/unreleased/unicorn-monkey-patch.yml new file mode 100644 index 00000000000..6b0e00ca291 --- /dev/null +++ b/changelogs/unreleased/unicorn-monkey-patch.yml @@ -0,0 +1,5 @@ +--- +title: Add monkey patch to unicorn to fix eof? problem +merge_request: 23385 +author: +type: fixed diff --git a/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-1-39.yml b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-1-39.yml new file mode 100644 index 00000000000..dffcdb0bb5a --- /dev/null +++ b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-1-39.yml @@ -0,0 +1,5 @@ +--- +title: Update used version of Runner Helm Chart to 0.1.39 +merge_request: 23633 +author: +type: other diff --git a/changelogs/unreleased/usage-count.yml b/changelogs/unreleased/usage-count.yml new file mode 100644 index 00000000000..efff2615ce4 --- /dev/null +++ b/changelogs/unreleased/usage-count.yml @@ -0,0 +1,5 @@ +--- +title: Use approximate count for big tables for usage statistics. +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/winh-collapse-discussions.yml b/changelogs/unreleased/winh-collapse-discussions.yml new file mode 100644 index 00000000000..19d04506318 --- /dev/null +++ b/changelogs/unreleased/winh-collapse-discussions.yml @@ -0,0 +1,5 @@ +--- +title: Fix collapsing discussion replies +merge_request: 23462 +author: +type: fixed diff --git a/changelogs/unreleased/winh-divider-margin.yml b/changelogs/unreleased/winh-divider-margin.yml new file mode 100644 index 00000000000..db84090c15c --- /dev/null +++ b/changelogs/unreleased/winh-divider-margin.yml @@ -0,0 +1,5 @@ +--- +title: Adjust divider margin to comply with design specs +merge_request: 23548 +author: +type: changed diff --git a/changelogs/unreleased/winh-dropdown-divider-color.yml b/changelogs/unreleased/winh-dropdown-divider-color.yml new file mode 100644 index 00000000000..6b6ecd831b8 --- /dev/null +++ b/changelogs/unreleased/winh-dropdown-divider-color.yml @@ -0,0 +1,5 @@ +--- +title: Change dropdown divider color to gray-200 (#dfdfdf) +merge_request: 23592 +author: +type: changed diff --git a/changelogs/unreleased/winh-issue-boards-project-dropdown-close.yml b/changelogs/unreleased/winh-issue-boards-project-dropdown-close.yml new file mode 100644 index 00000000000..18f7da56edb --- /dev/null +++ b/changelogs/unreleased/winh-issue-boards-project-dropdown-close.yml @@ -0,0 +1,5 @@ +--- +title: Remove close icon from projects dropdown in issue boards +merge_request: 23567 +author: +type: changed diff --git a/changelogs/unreleased/winh-merge-request-commit-discussion.yml b/changelogs/unreleased/winh-merge-request-commit-discussion.yml new file mode 100644 index 00000000000..b0c6264369b --- /dev/null +++ b/changelogs/unreleased/winh-merge-request-commit-discussion.yml @@ -0,0 +1,5 @@ +--- +title: Display commit ID for commit diff discussion on merge request +merge_request: 23370 +author: +type: fixed diff --git a/changelogs/unreleased/winh-merge-request-diff-discussion-commit-id.yml b/changelogs/unreleased/winh-merge-request-diff-discussion-commit-id.yml new file mode 100644 index 00000000000..2ce16a2b6b7 --- /dev/null +++ b/changelogs/unreleased/winh-merge-request-diff-discussion-commit-id.yml @@ -0,0 +1,5 @@ +--- +title: Pass commit when posting diff discussions +merge_request: 23371 +author: +type: fixed diff --git a/changelogs/unreleased/winh-milestone-select.yml b/changelogs/unreleased/winh-milestone-select.yml new file mode 100644 index 00000000000..8464fc6c541 --- /dev/null +++ b/changelogs/unreleased/winh-milestone-select.yml @@ -0,0 +1,5 @@ +--- +title: Fix milestone select in issue sidebar of issue boards +merge_request: 23625 +author: +type: fixed diff --git a/changelogs/unreleased/workhorse-7-3-0.yml b/changelogs/unreleased/workhorse-7-3-0.yml new file mode 100644 index 00000000000..6708b8a3cbb --- /dev/null +++ b/changelogs/unreleased/workhorse-7-3-0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade GitLab Workhorse to v7.3.0 +merge_request: 23489 +author: +type: other diff --git a/config.ru b/config.ru index 405d01863ac..a5d055334dd 100644 --- a/config.ru +++ b/config.ru @@ -13,6 +13,10 @@ if defined?(Unicorn) # Max memory size (RSS) per worker use Unicorn::WorkerKiller::Oom, min, max end + + # Monkey patch for fixing Rack 2.0.6 bug: + # https://gitlab.com/gitlab-org/gitlab-ee/issues/8539 + Unicorn::StreamInput.send(:public, :eof?) # rubocop:disable GitlabSecurity/PublicSend end require ::File.expand_path('../config/environment', __FILE__) diff --git a/config/application.rb b/config/application.rb index 5804d8fd27b..63a5b483fc2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -103,6 +103,9 @@ module Gitlab # - Webhook URLs (:hook) # - Sentry DSN (:sentry_dsn) # - File content from Web Editor (:content) + # + # NOTE: It is **IMPORTANT** to also update gitlab-workhorse's filter when adding parameters here to not + # introduce another security vulnerability: https://gitlab.com/gitlab-org/gitlab-workhorse/issues/182 config.filter_parameters += [/token$/, /password/, /secret/, /key$/] config.filter_parameters += %i( certificate diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 84d47bd52ad..6e4f7ce30a0 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -470,8 +470,8 @@ - - :license - pikaday - MIT - - :who: - :why: + - :who: Filipa Lacerda + :why: MIT License :versions: [] :when: 2017-10-17 17:46:12.367554000 Z - - :license @@ -592,3 +592,9 @@ in compiled/distributed product so attribution not needed. :versions: [] :when: 2018-10-02 19:23:54.840151000 Z +- - :approve + - echarts + - :who: Mike Greiling + :why: https://github.com/apache/incubator-echarts/blob/master/LICENSE + :versions: [] + :when: 2018-12-05 22:12:30.550027000 Z diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 09e21b2c6f2..1c16b999e55 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -114,6 +114,9 @@ production: &base # The default is 'shared/cache/archive/' relative to the root of the Rails app. # repository_downloads_path: shared/cache/archive/ + ## Impersonation settings + impersonation_enabled: true + ## Reply by email # Allow users to comment on issues and merge requests by replying to notification emails. # For documentation on how to set this up, see http://doc.gitlab.com/ce/administration/reply_by_email.html @@ -545,15 +548,15 @@ production: &base # app_id: 'YOUR_APP_ID', # app_secret: 'YOUR_APP_SECRET' } # - { name: 'jwt', - # app_secret: 'YOUR_APP_SECRET', # args: { - # algorithm: 'HS256', - # uid_claim: 'email', - # required_claims: ["name", "email"], - # info_map: { name: "name", email: "email" }, - # auth_url: 'https://example.com/', - # valid_within: null, - # } + # secret: 'YOUR_APP_SECRET', + # algorithm: 'HS256', # Supported algorithms: 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512' + # uid_claim: 'email', + # required_claims: ['name', 'email'], + # info_map: { name: 'name', email: 'email' }, + # auth_url: 'https://example.com/', + # valid_within: 3600 # 1 hour + # } # } # - { name: 'saml', # label: 'Our SAML Provider', diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index bd02b85c7ce..db35fa96ea2 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -153,6 +153,7 @@ Settings.gitlab['domain_whitelist'] ||= [] Settings.gitlab['import_sources'] ||= Gitlab::ImportSources.values Settings.gitlab['trusted_proxies'] ||= [] Settings.gitlab['no_todos_messages'] ||= YAML.load_file(Rails.root.join('config', 'no_todos_messages.yml')) +Settings.gitlab['impersonation_enabled'] ||= true if Settings.gitlab['impersonation_enabled'].nil? Settings.gitlab['usage_ping_enabled'] = true if Settings.gitlab['usage_ping_enabled'].nil? # @@ -301,10 +302,6 @@ Settings.cron_jobs['gitlab_usage_ping_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_for_usage_ping) Settings.cron_jobs['gitlab_usage_ping_worker']['job_class'] = 'GitlabUsagePingWorker' -Settings.cron_jobs['remove_old_web_hook_logs_worker'] ||= Settingslogic.new({}) -Settings.cron_jobs['remove_old_web_hook_logs_worker']['cron'] ||= '40 0 * * *' -Settings.cron_jobs['remove_old_web_hook_logs_worker']['job_class'] = 'RemoveOldWebHookLogsWorker' - Settings.cron_jobs['stuck_merge_jobs_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_merge_jobs_worker']['cron'] ||= '0 */2 * * *' Settings.cron_jobs['stuck_merge_jobs_worker']['job_class'] = 'StuckMergeJobsWorker' diff --git a/config/initializers/action_dispatch_http_mime_negotiation.rb b/config/initializers/action_dispatch_http_mime_negotiation.rb new file mode 100644 index 00000000000..bdf5b0babfb --- /dev/null +++ b/config/initializers/action_dispatch_http_mime_negotiation.rb @@ -0,0 +1,19 @@ +# Starting with Rails 5, Rails tries to determine the request format based on +# the extension of the full URL path if no explicit `format` param or `Accept` +# header is provided, like when simply browsing to a page in your browser. +# +# This is undesireable in GitLab, because many of our paths will end in a ref or +# blob name that can end with any extension, while these pages should still be +# presented as HTML unless otherwise specified. + +# We override `format_from_path_extension` to disable this behavior. + +module ActionDispatch + module Http + module MimeNegotiation + def format_from_path_extension + nil + end + end + end +end diff --git a/config/initializers/correlation_id.rb b/config/initializers/correlation_id.rb new file mode 100644 index 00000000000..2a7c138dc40 --- /dev/null +++ b/config/initializers/correlation_id.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Rails.application.config.middleware.use(Gitlab::Middleware::CorrelationId) diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 179e00cdbd0..67eabb0b4fc 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -103,6 +103,9 @@ Devise.setup do |config| # Send a notification email when the user's password is changed config.send_password_change_notification = true + # Send a notification email when the user's email is changed + config.send_email_changed_notification = true + # ==> Configuration for :validatable # Range for password length. Default is 6..128. config.password_length = 8..128 diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index f321b4ea763..6be5c00daaa 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -48,6 +48,13 @@ Doorkeeper.configure do # force_ssl_in_redirect_uri false + # Specify what redirect URI's you want to block during Application creation. + # Any redirect URI is whitelisted by default. + # + # You can use this option in order to forbid URI's with 'javascript' scheme + # for example. + forbid_redirect_uri { |uri| %w[data vbscript javascript].include?(uri.scheme.to_s.downcase) } + # Provide support for an owner to be assigned to each registered application (disabled by default) # Optional parameter confirmation: true (default false) if you want to enforce ownership of # a registered application diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb index 840404e0ec0..c897bc30e76 100644 --- a/config/initializers/lograge.rb +++ b/config/initializers/lograge.rb @@ -29,6 +29,7 @@ unless Sidekiq.server? gitaly_calls = Gitlab::GitalyClient.get_request_count payload[:gitaly_calls] = gitaly_calls if gitaly_calls > 0 payload[:response] = event.payload[:response] if event.payload[:response] + payload[Gitlab::CorrelationId::LOG_KEY] = Gitlab::CorrelationId.current_id payload end diff --git a/config/initializers/rack_attack_global.rb b/config/initializers/rack_attack_global.rb index 45963831c41..86cb930eca9 100644 --- a/config/initializers/rack_attack_global.rb +++ b/config/initializers/rack_attack_global.rb @@ -33,22 +33,22 @@ class Rack::Attack throttle('throttle_authenticated_api', Gitlab::Throttle.authenticated_api_options) do |req| Gitlab::Throttle.settings.throttle_authenticated_api_enabled && req.api_request? && - req.authenticated_user_id + req.authenticated_user_id([:api]) end throttle('throttle_authenticated_web', Gitlab::Throttle.authenticated_web_options) do |req| Gitlab::Throttle.settings.throttle_authenticated_web_enabled && req.web_request? && - req.authenticated_user_id + req.authenticated_user_id([:api, :rss, :ics]) end class Request def unauthenticated? - !authenticated_user_id + !authenticated_user_id([:api, :rss, :ics]) end - def authenticated_user_id - Gitlab::Auth::RequestAuthenticator.new(self).user&.id + def authenticated_user_id(request_formats) + Gitlab::Auth::RequestAuthenticator.new(self).user(request_formats)&.id end def api_request? diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index 17d09293205..2a6c5148f71 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -24,4 +24,4 @@ def configure_sentry end end -configure_sentry if Rails.env.production? +configure_sentry if Rails.env.production? || Rails.env.development? diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 4210be2c701..6aba6c7c21d 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -21,6 +21,7 @@ Sidekiq.configure_server do |config| chain.add Gitlab::SidekiqMiddleware::Shutdown chain.add Gitlab::SidekiqMiddleware::RequestStoreMiddleware unless ENV['SIDEKIQ_REQUEST_STORE'] == '0' chain.add Gitlab::SidekiqMiddleware::BatchLoader + chain.add Gitlab::SidekiqMiddleware::CorrelationLogger chain.add Gitlab::SidekiqStatus::ServerMiddleware end @@ -31,6 +32,7 @@ Sidekiq.configure_server do |config| config.client_middleware do |chain| chain.add Gitlab::SidekiqStatus::ClientMiddleware + chain.add Gitlab::SidekiqMiddleware::CorrelationInjector end config.on :startup do @@ -39,7 +41,7 @@ Sidekiq.configure_server do |config| ActiveRecord::Base.clear_all_connections! end - if Feature.enabled?(:gitlab_sidekiq_reliable_fetcher) + if Feature::FlipperFeature.table_exists? && Feature.enabled?(:gitlab_sidekiq_reliable_fetcher) Sidekiq::ReliableFetcher.setup_reliable_fetch!(config) end @@ -75,6 +77,7 @@ Sidekiq.configure_client do |config| config.redis = queues_config_hash config.client_middleware do |chain| + chain.add Gitlab::SidekiqMiddleware::CorrelationInjector chain.add Gitlab::SidekiqStatus::ClientMiddleware end end diff --git a/config/jest.config.js b/config/jest.config.js new file mode 100644 index 00000000000..23e62f49be1 --- /dev/null +++ b/config/jest.config.js @@ -0,0 +1,27 @@ +/* eslint-disable filenames/match-regex */ + +const reporters = ['default']; + +if (process.env.CI) { + reporters.push([ + 'jest-junit', + { + output: './junit_jest.xml', + }, + ]); +} + +// eslint-disable-next-line import/no-commonjs +module.exports = { + testMatch: ['<rootDir>/spec/frontend/**/*_spec.js'], + moduleNameMapper: { + '^~(.*)$': '<rootDir>/app/assets/javascripts$1', + }, + collectCoverageFrom: ['<rootDir>/app/assets/javascripts/**/*.{js,vue}'], + coverageDirectory: '<rootDir>/coverage-frontend/', + coverageReporters: ['json', 'lcov', 'text-summary', 'clover'], + cacheDirectory: '<rootDir>/tmp/cache/jest', + modulePathIgnorePatterns: ['<rootDir>/.yarn-cache/'], + reporters, + rootDir: '..', // necessary because this file is in the config/ subdirectory +}; diff --git a/config/routes/project.rb b/config/routes/project.rb index 3f1ad90dfca..7d0623cb904 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -245,6 +245,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end + namespace :serverless do + resources :functions, only: [:index] + end + scope '-' do get 'archive/*id', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+?/ }, to: 'repositories#archive', as: 'archive' @@ -432,6 +436,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do resource :integrations, only: [:show] resource :repository, only: [:show], controller: :repository do post :create_deploy_token, path: 'deploy_token/create' + post :cleanup end end diff --git a/config/routes/wiki.rb b/config/routes/wiki.rb index 1a07b1c206b..2ca52e55fca 100644 --- a/config/routes/wiki.rb +++ b/config/routes/wiki.rb @@ -6,7 +6,7 @@ scope(controller: :wikis) do post '/', to: 'wikis#create' end - scope(path: 'wikis/*id', as: :wiki, format: false, defaults: { format: :html }) do + scope(path: 'wikis/*id', as: :wiki, format: false) do get :edit get :history post :preview_markdown diff --git a/config/settings.rb b/config/settings.rb index 3f3481bb65d..1b94df785a7 100644 --- a/config/settings.rb +++ b/config/settings.rb @@ -95,6 +95,14 @@ class Settings < Settingslogic Gitlab::Application.secrets.db_key_base[0..31] end + def attr_encrypted_db_key_base_32 + Gitlab::Utils.ensure_utf8_size(attr_encrypted_db_key_base, bytes: 32.bytes) + end + + def attr_encrypted_db_key_base_12 + Gitlab::Utils.ensure_utf8_size(attr_encrypted_db_key_base, bytes: 12.bytes) + end + # This should be used for :per_attribute_salt_and_iv mode. There is no # need to truncate the key because the encryptor will use the salt to # generate a hash of the password: diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 53e1c8778b6..4782a223561 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -81,3 +81,5 @@ - [delete_diff_files, 1] - [detect_repository_languages, 1] - [auto_devops, 2] + - [repository_cleanup, 1] + - [delete_stored_files, 1] diff --git a/danger/changelog/Dangerfile b/danger/changelog/Dangerfile index 713ed95a04c..530c6638653 100644 --- a/danger/changelog/Dangerfile +++ b/danger/changelog/Dangerfile @@ -2,7 +2,7 @@ require 'yaml' -NO_CHANGELOG_LABELS = %w[backstage Documentation QA test].freeze +NO_CHANGELOG_LABELS = %w[backstage ci-build Documentation meta QA test].freeze SEE_DOC = "See [the documentation](https://docs.gitlab.com/ce/development/changelog.html).".freeze CREATE_CHANGELOG_MESSAGE = <<~MSG.freeze You can create one with: diff --git a/danger/documentation/Dangerfile b/danger/documentation/Dangerfile index 87c61d6e90d..be7b301866d 100644 --- a/danger/documentation/Dangerfile +++ b/danger/documentation/Dangerfile @@ -24,23 +24,24 @@ The following files require a review from the Documentation team: * #{docs_paths_to_review.map { |path| "`#{path}`" }.join("\n* ")} -When your content is ready for review, mention a technical writer in a separate -comment and explain what needs to be reviewed. - -You are welcome to mention them sooner if you have questions about writing or updating -the documentation. GitLabbers are also welcome to use the [#docs](https://gitlab.slack.com/archives/C16HYA2P5) channel on Slack. - -Who to ping [based on DevOps stages](https://about.gitlab.com/handbook/product/categories/#devops-stages): +When your content is ready for review, assign the MR to a technical writer +according to the [DevOps stages](https://about.gitlab.com/handbook/product/categories/#devops-stages) +in the table below. If necessary, mention them in a comment explaining what needs +to be reviewed. | Tech writer | Stage(s) | | ------------ | ------------------------------------------------------------ | -| `@marcia` | ~Create ~Release | +| `@marcia` | ~Create ~Release + ~"development guidelines" | | `@axil` | ~Distribution ~Gitaly ~Gitter ~Monitoring ~Packaging ~Secure | | `@eread` | ~Manage ~Configure ~Geo ~Verify | | `@mikelewis` | ~Plan | +You are welcome to mention them sooner if you have questions about writing or +updating the documentation. GitLabbers are also welcome to use the +[#docs](https://gitlab.slack.com/archives/C16HYA2P5) channel on Slack. + If you are not sure which category the change falls within, or the change is not -part of one of these categories, you can mention one of the usernames above. +part of one of these categories, mention one of the usernames above. MARKDOWN unless gitlab.mr_labels.include?('Documentation') diff --git a/db/fixtures/development/04_project.rb b/db/fixtures/development/04_project.rb index 089de211380..aa8686ac7d8 100644 --- a/db/fixtures/development/04_project.rb +++ b/db/fixtures/development/04_project.rb @@ -71,13 +71,17 @@ Sidekiq::Testing.inline! do params[:storage_version] = Project::LATEST_STORAGE_VERSION end - project = Projects::CreateService.new(User.first, params).execute - # Seed-Fu runs this entire fixture in a transaction, so the `after_commit` - # hook won't run until after the fixture is loaded. That is too late - # since the Sidekiq::Testing block has already exited. Force clearing - # the `after_commit` queue to ensure the job is run now. + project = nil + Sidekiq::Worker.skipping_transaction_check do + project = Projects::CreateService.new(User.first, params).execute + + # Seed-Fu runs this entire fixture in a transaction, so the `after_commit` + # hook won't run until after the fixture is loaded. That is too late + # since the Sidekiq::Testing block has already exited. Force clearing + # the `after_commit` queue to ensure the job is run now. project.send(:_run_after_commit_queue) + project.import_state.send(:_run_after_commit_queue) end if project.valid? && project.valid_repo? diff --git a/db/fixtures/development/10_merge_requests.rb b/db/fixtures/development/10_merge_requests.rb index bcfdd058a1c..8bdc7c6556c 100644 --- a/db/fixtures/development/10_merge_requests.rb +++ b/db/fixtures/development/10_merge_requests.rb @@ -25,7 +25,9 @@ Gitlab::Seeder.quiet do developer = project.team.developers.sample break unless developer - MergeRequests::CreateService.new(project, developer, params).execute + Sidekiq::Worker.skipping_transaction_check do + MergeRequests::CreateService.new(project, developer, params).execute + end print '.' end end @@ -39,7 +41,9 @@ Gitlab::Seeder.quiet do target_branch: 'master', title: 'Can be automatically merged' } - MergeRequests::CreateService.new(project, User.admins.first, params).execute + Sidekiq::Worker.skipping_transaction_check do + MergeRequests::CreateService.new(project, User.admins.first, params).execute + end print '.' params = { @@ -47,6 +51,8 @@ Gitlab::Seeder.quiet do target_branch: 'feature', title: 'Cannot be automatically merged' } - MergeRequests::CreateService.new(project, User.admins.first, params).execute + Sidekiq::Worker.skipping_transaction_check do + MergeRequests::CreateService.new(project, User.admins.first, params).execute + end print '.' end diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb index 5af77c49913..bdc0a2db7db 100644 --- a/db/fixtures/development/14_pipelines.rb +++ b/db/fixtures/development/14_pipelines.rb @@ -104,7 +104,7 @@ class Gitlab::Seeder::Pipelines def create_pipeline!(project, ref, commit) - project.pipelines.create!(sha: commit.id, ref: ref, source: :push) + project.ci_pipelines.create!(sha: commit.id, ref: ref, source: :push) end def build_create!(pipeline, opts = {}) diff --git a/db/fixtures/development/24_forks.rb b/db/fixtures/development/24_forks.rb new file mode 100644 index 00000000000..61e39c871e6 --- /dev/null +++ b/db/fixtures/development/24_forks.rb @@ -0,0 +1,16 @@ +require './spec/support/sidekiq' + +Sidekiq::Testing.inline! do + Gitlab::Seeder.quiet do + User.all.sample(10).each do |user| + source_project = Project.public_only.sample + fork_project = Projects::ForkService.new(source_project, user, namespace: user.namespace).execute + + if fork_project.valid? + puts '.' + else + puts 'F' + end + end + end +end diff --git a/db/fixtures/production/001_application_settings.rb b/db/fixtures/production/001_application_settings.rb new file mode 100644 index 00000000000..ab15717e9a9 --- /dev/null +++ b/db/fixtures/production/001_application_settings.rb @@ -0,0 +1,2 @@ +puts "Creating the default ApplicationSetting record.".color(:green) +Gitlab::CurrentSettings.current_application_settings diff --git a/db/fixtures/production/001_admin.rb b/db/fixtures/production/002_admin.rb index 1c7c89f7bbd..1c7c89f7bbd 100644 --- a/db/fixtures/production/001_admin.rb +++ b/db/fixtures/production/002_admin.rb diff --git a/db/migrate/20181108091549_cleanup_environments_external_url.rb b/db/migrate/20181108091549_cleanup_environments_external_url.rb new file mode 100644 index 00000000000..8d6c20a4b15 --- /dev/null +++ b/db/migrate/20181108091549_cleanup_environments_external_url.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CleanupEnvironmentsExternalUrl < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + update_column_in_batches(:environments, :external_url, nil) do |table, query| + query.where(table[:external_url].matches('javascript://%')) + end + end + + def down + end +end diff --git a/db/migrate/20181115140140_add_encrypted_runners_token_to_settings.rb b/db/migrate/20181115140140_add_encrypted_runners_token_to_settings.rb new file mode 100644 index 00000000000..36d9ad45b19 --- /dev/null +++ b/db/migrate/20181115140140_add_encrypted_runners_token_to_settings.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddEncryptedRunnersTokenToSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :application_settings, :runners_registration_token_encrypted, :string + end +end diff --git a/db/migrate/20181116141415_add_encrypted_runners_token_to_namespaces.rb b/db/migrate/20181116141415_add_encrypted_runners_token_to_namespaces.rb new file mode 100644 index 00000000000..b92b1b50218 --- /dev/null +++ b/db/migrate/20181116141415_add_encrypted_runners_token_to_namespaces.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddEncryptedRunnersTokenToNamespaces < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :namespaces, :runners_token_encrypted, :string + end +end diff --git a/db/migrate/20181116141504_add_encrypted_runners_token_to_projects.rb b/db/migrate/20181116141504_add_encrypted_runners_token_to_projects.rb new file mode 100644 index 00000000000..53e475bd180 --- /dev/null +++ b/db/migrate/20181116141504_add_encrypted_runners_token_to_projects.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddEncryptedRunnersTokenToProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :projects, :runners_token_encrypted, :string + end +end diff --git a/db/migrate/20181119081539_add_merge_request_id_to_ci_pipelines.rb b/db/migrate/20181119081539_add_merge_request_id_to_ci_pipelines.rb new file mode 100644 index 00000000000..65476109c61 --- /dev/null +++ b/db/migrate/20181119081539_add_merge_request_id_to_ci_pipelines.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddMergeRequestIdToCiPipelines < ActiveRecord::Migration + DOWNTIME = false + + def up + add_column :ci_pipelines, :merge_request_id, :integer + end + + def down + remove_column :ci_pipelines, :merge_request_id, :integer + end +end diff --git a/db/migrate/20181120082911_rename_repositories_pool_repositories.rb b/db/migrate/20181120082911_rename_repositories_pool_repositories.rb new file mode 100644 index 00000000000..165771c4775 --- /dev/null +++ b/db/migrate/20181120082911_rename_repositories_pool_repositories.rb @@ -0,0 +1,11 @@ +class RenameRepositoriesPoolRepositories < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + # This change doesn't require downtime as the table is not in use, so we're + # free to change an empty table + DOWNTIME = false + + def change + rename_table :repositories, :pool_repositories + end +end diff --git a/db/migrate/20181120091639_add_foreign_key_to_ci_pipelines_merge_requests.rb b/db/migrate/20181120091639_add_foreign_key_to_ci_pipelines_merge_requests.rb new file mode 100644 index 00000000000..03f677a4678 --- /dev/null +++ b/db/migrate/20181120091639_add_foreign_key_to_ci_pipelines_merge_requests.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddForeignKeyToCiPipelinesMergeRequests < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :ci_pipelines, :merge_request_id, where: 'merge_request_id IS NOT NULL' + add_concurrent_foreign_key :ci_pipelines, :merge_requests, column: :merge_request_id, on_delete: :cascade + end + + def down + if foreign_key_exists?(:ci_pipelines, :merge_requests, column: :merge_request_id) + remove_foreign_key :ci_pipelines, :merge_requests + end + + remove_concurrent_index :ci_pipelines, :merge_request_id, where: 'merge_request_id IS NOT NULL' + end +end diff --git a/db/migrate/20181120151656_add_token_encrypted_to_ci_runners.rb b/db/migrate/20181120151656_add_token_encrypted_to_ci_runners.rb new file mode 100644 index 00000000000..40db6b399ab --- /dev/null +++ b/db/migrate/20181120151656_add_token_encrypted_to_ci_runners.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddTokenEncryptedToCiRunners < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_runners, :token_encrypted, :string + end +end diff --git a/db/migrate/20181121101842_add_ci_builds_partial_index_on_project_id_and_status.rb b/db/migrate/20181121101842_add_ci_builds_partial_index_on_project_id_and_status.rb new file mode 100644 index 00000000000..5b47a279438 --- /dev/null +++ b/db/migrate/20181121101842_add_ci_builds_partial_index_on_project_id_and_status.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddCiBuildsPartialIndexOnProjectIdAndStatus < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index(*index_arguments) + end + + def down + remove_concurrent_index(*index_arguments) + end + + private + + def index_arguments + [ + :ci_builds, + [:project_id, :status], + { + name: 'index_ci_builds_project_id_and_status_for_live_jobs_partial2', + where: "(((type)::text = 'Ci::Build'::text) AND ((status)::text = ANY (ARRAY[('running'::character varying)::text, ('pending'::character varying)::text, ('created'::character varying)::text])))" + } + ] + end +end diff --git a/db/migrate/20181121101843_remove_redundant_ci_builds_partial_index.rb b/db/migrate/20181121101843_remove_redundant_ci_builds_partial_index.rb new file mode 100644 index 00000000000..a0a02e81323 --- /dev/null +++ b/db/migrate/20181121101843_remove_redundant_ci_builds_partial_index.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveRedundantCiBuildsPartialIndex < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + remove_concurrent_index(*index_arguments) + end + + def down + add_concurrent_index(*index_arguments) + end + + private + + def index_arguments + [ + :ci_builds, + [:project_id, :status], + { + name: 'index_ci_builds_project_id_and_status_for_live_jobs_partial', + where: "((status)::text = ANY (ARRAY[('running'::character varying)::text, ('pending'::character varying)::text, ('created'::character varying)::text]))" + } + ] + end +end diff --git a/db/migrate/20181122160027_create_project_repositories.rb b/db/migrate/20181122160027_create_project_repositories.rb new file mode 100644 index 00000000000..e42cef9b1c6 --- /dev/null +++ b/db/migrate/20181122160027_create_project_repositories.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class CreateProjectRepositories < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :project_repositories, id: :bigserial do |t| + t.references :shard, null: false, index: true, foreign_key: { on_delete: :restrict } + t.string :disk_path, null: false, index: { unique: true } + t.references :project, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade } + end + end +end diff --git a/db/migrate/20181123135036_drop_not_null_constraint_pool_repository_disk_path.rb b/db/migrate/20181123135036_drop_not_null_constraint_pool_repository_disk_path.rb new file mode 100644 index 00000000000..bcd969e91c5 --- /dev/null +++ b/db/migrate/20181123135036_drop_not_null_constraint_pool_repository_disk_path.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class DropNotNullConstraintPoolRepositoryDiskPath < ActiveRecord::Migration[5.0] + DOWNTIME = false + + def change + change_column_null :pool_repositories, :disk_path, true + end +end diff --git a/db/migrate/20181129104854_add_token_encrypted_to_ci_builds.rb b/db/migrate/20181129104854_add_token_encrypted_to_ci_builds.rb new file mode 100644 index 00000000000..11b98203793 --- /dev/null +++ b/db/migrate/20181129104854_add_token_encrypted_to_ci_builds.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddTokenEncryptedToCiBuilds < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_builds, :token_encrypted, :string + end +end diff --git a/db/migrate/20181129104944_add_index_to_ci_builds_token_encrypted.rb b/db/migrate/20181129104944_add_index_to_ci_builds_token_encrypted.rb new file mode 100644 index 00000000000..f90aca008e5 --- /dev/null +++ b/db/migrate/20181129104944_add_index_to_ci_builds_token_encrypted.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexToCiBuildsTokenEncrypted < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :ci_builds, :token_encrypted, unique: true, where: 'token_encrypted IS NOT NULL' + end + + def down + remove_concurrent_index :ci_builds, :token_encrypted + end +end diff --git a/db/migrate/20181203002526_add_project_bfg_object_map_column.rb b/db/migrate/20181203002526_add_project_bfg_object_map_column.rb new file mode 100644 index 00000000000..8b42cd6f941 --- /dev/null +++ b/db/migrate/20181203002526_add_project_bfg_object_map_column.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddProjectBfgObjectMapColumn < ActiveRecord::Migration[5.0] + DOWNTIME = false + + def change + add_column :projects, :bfg_object_map, :string + end +end diff --git a/db/post_migrate/20181010133639_backfill_store_project_full_path_in_repo.rb b/db/post_migrate/20181010133639_backfill_store_project_full_path_in_repo.rb new file mode 100644 index 00000000000..e9ab45ae9a1 --- /dev/null +++ b/db/post_migrate/20181010133639_backfill_store_project_full_path_in_repo.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class BackfillStoreProjectFullPathInRepo < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + BATCH_SIZE = 1_000 + DELAY_INTERVAL = 5.minutes + UP_MIGRATION = 'BackfillProjectFullpathInRepoConfig::Up' + DOWN_MIGRATION = 'BackfillProjectFullpathInRepoConfig::Down' + + disable_ddl_transaction! + + class Project < ActiveRecord::Base + self.table_name = 'projects' + + include EachBatch + end + + def up + queue_background_migration_jobs_by_range_at_intervals(Project, UP_MIGRATION, DELAY_INTERVAL) + end + + def down + queue_background_migration_jobs_by_range_at_intervals(Project, DOWN_MIGRATION, DELAY_INTERVAL) + end +end diff --git a/db/post_migrate/20181026091631_migrate_forbidden_redirect_uris.rb b/db/post_migrate/20181026091631_migrate_forbidden_redirect_uris.rb new file mode 100644 index 00000000000..ff5510e8eb7 --- /dev/null +++ b/db/post_migrate/20181026091631_migrate_forbidden_redirect_uris.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class MigrateForbiddenRedirectUris < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + FORBIDDEN_SCHEMES = %w[data:// vbscript:// javascript://] + NEW_URI = 'http://forbidden-scheme-has-been-overwritten' + + disable_ddl_transaction! + + def up + update_forbidden_uris(:oauth_applications) + update_forbidden_uris(:oauth_access_grants) + end + + def down + # noop + end + + private + + def update_forbidden_uris(table_name) + update_column_in_batches(table_name, :redirect_uri, NEW_URI) do |table, query| + where_clause = FORBIDDEN_SCHEMES.map do |scheme| + table[:redirect_uri].matches("#{scheme}%") + end.inject(&:or) + + query.where(where_clause) + end + end +end diff --git a/db/post_migrate/20181121111200_schedule_runners_token_encryption.rb b/db/post_migrate/20181121111200_schedule_runners_token_encryption.rb new file mode 100644 index 00000000000..753e052f7a7 --- /dev/null +++ b/db/post_migrate/20181121111200_schedule_runners_token_encryption.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ScheduleRunnersTokenEncryption < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + BATCH_SIZE = 10000 + RANGE_SIZE = 2000 + MIGRATION = 'EncryptRunnersTokens' + + MODELS = [ + ::Gitlab::BackgroundMigration::Models::EncryptColumns::Settings, + ::Gitlab::BackgroundMigration::Models::EncryptColumns::Namespace, + ::Gitlab::BackgroundMigration::Models::EncryptColumns::Project, + ::Gitlab::BackgroundMigration::Models::EncryptColumns::Runner + ].freeze + + disable_ddl_transaction! + + def up + MODELS.each do |model| + model.each_batch(of: BATCH_SIZE) do |relation, index| + delay = index * 4.minutes + + relation.each_batch(of: RANGE_SIZE) do |relation| + range = relation.pluck('MIN(id)', 'MAX(id)').first + args = [model.name.demodulize.downcase, *range] + + BackgroundMigrationWorker.perform_in(delay, MIGRATION, args) + end + end + end + end + + def down + # no-op + end +end diff --git a/db/migrate/20181123042307_drop_site_statistics.rb b/db/post_migrate/20181123042307_drop_site_statistics.rb index 8986374ef65..8986374ef65 100644 --- a/db/migrate/20181123042307_drop_site_statistics.rb +++ b/db/post_migrate/20181123042307_drop_site_statistics.rb diff --git a/db/schema.rb b/db/schema.rb index 9c9c19aa897..d7124100621 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20181126153547) do +ActiveRecord::Schema.define(version: 20181203002526) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -166,6 +166,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.integer "diff_max_patch_bytes", default: 102400, null: false t.integer "archive_builds_in_seconds" t.string "commit_email_hostname" + t.string "runners_registration_token_encrypted" t.index ["usage_stats_set_by_user_id"], name: "index_application_settings_on_usage_stats_set_by_user_id", using: :btree end @@ -344,6 +345,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.boolean "protected" t.integer "failure_reason" t.datetime_with_timezone "scheduled_at" + t.string "token_encrypted" t.index ["artifacts_expire_at"], name: "index_ci_builds_on_artifacts_expire_at", where: "(artifacts_file <> ''::text)", using: :btree t.index ["auto_canceled_by_id"], name: "index_ci_builds_on_auto_canceled_by_id", using: :btree t.index ["commit_id", "stage_idx", "created_at"], name: "index_ci_builds_on_commit_id_and_stage_idx_and_created_at", using: :btree @@ -352,6 +354,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.index ["commit_id", "type", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_ref", using: :btree t.index ["id"], name: "partial_index_ci_builds_on_id_with_legacy_artifacts", where: "(artifacts_file <> ''::text)", using: :btree t.index ["project_id", "id"], name: "index_ci_builds_on_project_id_and_id", using: :btree + t.index ["project_id", "status"], name: "index_ci_builds_project_id_and_status_for_live_jobs_partial2", where: "(((type)::text = 'Ci::Build'::text) AND ((status)::text = ANY (ARRAY[('running'::character varying)::text, ('pending'::character varying)::text, ('created'::character varying)::text])))", using: :btree t.index ["protected"], name: "index_ci_builds_on_protected", using: :btree t.index ["runner_id"], name: "index_ci_builds_on_runner_id", using: :btree t.index ["scheduled_at"], name: "partial_index_ci_builds_on_scheduled_at_with_scheduled_jobs", where: "((scheduled_at IS NOT NULL) AND ((type)::text = 'Ci::Build'::text) AND ((status)::text = 'scheduled'::text))", using: :btree @@ -359,6 +362,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.index ["stage_id"], name: "index_ci_builds_on_stage_id", using: :btree t.index ["status", "type", "runner_id"], name: "index_ci_builds_on_status_and_type_and_runner_id", using: :btree t.index ["token"], name: "index_ci_builds_on_token", unique: true, using: :btree + t.index ["token_encrypted"], name: "index_ci_builds_on_token_encrypted", unique: true, where: "(token_encrypted IS NOT NULL)", using: :btree t.index ["updated_at"], name: "index_ci_builds_on_updated_at", using: :btree t.index ["user_id"], name: "index_ci_builds_on_user_id", using: :btree end @@ -473,7 +477,9 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.boolean "protected" t.integer "failure_reason" t.integer "iid" + t.integer "merge_request_id" t.index ["auto_canceled_by_id"], name: "index_ci_pipelines_on_auto_canceled_by_id", using: :btree + t.index ["merge_request_id"], name: "index_ci_pipelines_on_merge_request_id", where: "(merge_request_id IS NOT NULL)", using: :btree t.index ["pipeline_schedule_id"], name: "index_ci_pipelines_on_pipeline_schedule_id", using: :btree t.index ["project_id", "iid"], name: "index_ci_pipelines_on_project_id_and_iid", unique: true, where: "(iid IS NOT NULL)", using: :btree t.index ["project_id", "ref", "status", "id"], name: "index_ci_pipelines_on_project_id_and_ref_and_status_and_id", using: :btree @@ -520,6 +526,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.string "ip_address" t.integer "maximum_timeout" t.integer "runner_type", limit: 2, null: false + t.string "token_encrypted" t.index ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree t.index ["is_shared"], name: "index_ci_runners_on_is_shared", using: :btree t.index ["locked"], name: "index_ci_runners_on_locked", using: :btree @@ -1335,6 +1342,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.integer "two_factor_grace_period", default: 48, null: false t.integer "cached_markdown_version" t.string "runners_token" + t.string "runners_token_encrypted" t.index ["created_at"], name: "index_namespaces_on_created_at", using: :btree t.index ["name", "parent_id"], name: "index_namespaces_on_name_and_parent_id", unique: true, using: :btree t.index ["name"], name: "index_namespaces_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} @@ -1501,6 +1509,13 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.index ["user_id"], name: "index_personal_access_tokens_on_user_id", using: :btree end + create_table "pool_repositories", id: :bigserial, force: :cascade do |t| + t.integer "shard_id", null: false + t.string "disk_path" + t.index ["disk_path"], name: "index_pool_repositories_on_disk_path", unique: true, using: :btree + t.index ["shard_id"], name: "index_pool_repositories_on_shard_id", using: :btree + end + create_table "programming_languages", force: :cascade do |t| t.string "name", null: false t.string "color", null: false @@ -1594,6 +1609,15 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.index ["status"], name: "index_project_mirror_data_on_status", using: :btree end + create_table "project_repositories", id: :bigserial, force: :cascade do |t| + t.integer "shard_id", null: false + t.string "disk_path", null: false + t.integer "project_id", null: false + t.index ["disk_path"], name: "index_project_repositories_on_disk_path", unique: true, using: :btree + t.index ["project_id"], name: "index_project_repositories_on_project_id", unique: true, using: :btree + t.index ["shard_id"], name: "index_project_repositories_on_shard_id", using: :btree + end + create_table "project_statistics", force: :cascade do |t| t.integer "project_id", null: false t.integer "namespace_id", null: false @@ -1659,6 +1683,8 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.boolean "pages_https_only", default: true t.boolean "remote_mirror_available_overridden" t.bigint "pool_repository_id" + t.string "runners_token_encrypted" + t.string "bfg_object_map" t.index ["ci_id"], name: "index_projects_on_ci_id", using: :btree t.index ["created_at"], name: "index_projects_on_created_at", using: :btree t.index ["creator_id"], name: "index_projects_on_creator_id", using: :btree @@ -1798,13 +1824,6 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.index ["project_id"], name: "index_remote_mirrors_on_project_id", using: :btree end - create_table "repositories", id: :bigserial, force: :cascade do |t| - t.integer "shard_id", null: false - t.string "disk_path", null: false - t.index ["disk_path"], name: "index_repositories_on_disk_path", unique: true, using: :btree - t.index ["shard_id"], name: "index_repositories_on_shard_id", using: :btree - end - create_table "repository_languages", id: false, force: :cascade do |t| t.integer "project_id", null: false t.integer "programming_language_id", null: false @@ -2279,6 +2298,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do add_foreign_key "ci_pipeline_variables", "ci_pipelines", column: "pipeline_id", name: "fk_f29c5f4380", on_delete: :cascade add_foreign_key "ci_pipelines", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_3d34ab2e06", on_delete: :nullify add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify + add_foreign_key "ci_pipelines", "merge_requests", name: "fk_a23be95014", on_delete: :cascade add_foreign_key "ci_pipelines", "projects", name: "fk_86635dbd80", on_delete: :cascade add_foreign_key "ci_runner_namespaces", "ci_runners", column: "runner_id", on_delete: :cascade add_foreign_key "ci_runner_namespaces", "namespaces", on_delete: :cascade @@ -2373,6 +2393,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", name: "fk_oauth_openid_requests_oauth_access_grants_access_grant_id" add_foreign_key "pages_domains", "projects", name: "fk_ea2f6dfc6f", on_delete: :cascade add_foreign_key "personal_access_tokens", "users" + add_foreign_key "pool_repositories", "shards", on_delete: :restrict add_foreign_key "project_authorizations", "projects", on_delete: :cascade add_foreign_key "project_authorizations", "users", on_delete: :cascade add_foreign_key "project_auto_devops", "projects", on_delete: :cascade @@ -2384,8 +2405,10 @@ ActiveRecord::Schema.define(version: 20181126153547) do add_foreign_key "project_group_links", "projects", name: "fk_daa8cee94c", on_delete: :cascade add_foreign_key "project_import_data", "projects", name: "fk_ffb9ee3a10", on_delete: :cascade add_foreign_key "project_mirror_data", "projects", on_delete: :cascade + add_foreign_key "project_repositories", "projects", on_delete: :cascade + add_foreign_key "project_repositories", "shards", on_delete: :restrict add_foreign_key "project_statistics", "projects", on_delete: :cascade - add_foreign_key "projects", "repositories", column: "pool_repository_id", name: "fk_6e5c14658a", on_delete: :nullify + add_foreign_key "projects", "pool_repositories", name: "fk_6e5c14658a", on_delete: :nullify add_foreign_key "prometheus_metrics", "projects", on_delete: :cascade add_foreign_key "protected_branch_merge_access_levels", "protected_branches", name: "fk_8a3072ccb3", on_delete: :cascade add_foreign_key "protected_branch_push_access_levels", "protected_branches", name: "fk_9ffc86a3d9", on_delete: :cascade @@ -2397,7 +2420,6 @@ ActiveRecord::Schema.define(version: 20181126153547) do add_foreign_key "push_event_payloads", "events", name: "fk_36c74129da", on_delete: :cascade add_foreign_key "releases", "projects", name: "fk_47fe2a0596", on_delete: :cascade add_foreign_key "remote_mirrors", "projects", on_delete: :cascade - add_foreign_key "repositories", "shards", on_delete: :restrict add_foreign_key "repository_languages", "projects", on_delete: :cascade add_foreign_key "resource_label_events", "issues", on_delete: :cascade add_foreign_key "resource_label_events", "labels", on_delete: :nullify diff --git a/doc/README.md b/doc/README.md index bf93c73843f..b15c3a63d92 100644 --- a/doc/README.md +++ b/doc/README.md @@ -3,12 +3,18 @@ comments: false description: 'Learn how to use and administer GitLab, the most scalable Git-based fully integrated platform for software development.' --- +<div class="display-none"> + <em>Visit <a href="https://docs.gitlab.com/ce/">docs.gitlab.com</a> for optimized + navigation, discoverability, and readability.</em> +</div> +<!-- the div above will not display on the docs site but will display on /help --> + # GitLab Documentation Welcome to [GitLab](https://about.gitlab.com/) Documentation. Here you can access the complete documentation for GitLab, the single application for the -[entire DevOps lifecycle](#complete-devops-with-gitlab). +[entire DevOps lifecycle](#the-entire-devops-lifecycle). ## Overview @@ -72,11 +78,11 @@ GitLab provides statistics and insight into ways you can maximize the value of G The following documentation relates to the DevOps **Manage** stage: -| Manage Topics | Description | -|:----------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Authentication and Authorization](administration/auth/README.md) **[CORE ONLY]** | Supported authentication and authorization providers. | -| [GitLab Cycle Analytics](user/project/cycle_analytics.md) | Measure the time it takes to go from an [idea to production](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#from-idea-to-production-with-gitlab) for each project you have. | -| [Instance Statistics](user/instance_statistics/index.md) | Discover statistics on how many GitLab features you use and user activity. | +| Manage Topics | Description | +|:--------------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [Authentication and<br/>Authorization](administration/auth/README.md) **[CORE ONLY]** | Supported authentication and authorization providers. | +| [GitLab Cycle Analytics](user/project/cycle_analytics.md) | Measure the time it takes to go from an [idea to production](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#from-idea-to-production-with-gitlab) for each project you have. | +| [Instance Statistics](user/instance_statistics/index.md) | Discover statistics on how many GitLab features you use and user activity. | <div align="right"> <a type="button" class="btn btn-default" href="#overview"> @@ -93,17 +99,17 @@ management tools. The following documentation relates to the DevOps **Plan** stage: -| Plan Topics | Description | -|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------| -| [Discussions](user/discussions/index.md) | Threads, comments, and resolvable discussions in issues, commits, and merge requests. | -| [Due Dates](user/project/issues/due_dates.md) | Keep track of issue deadlines. | -| [Quick Actions](user/project/quick_actions.md) | Shortcuts for common actions on issues or merge requests, replacing the need to click buttons or use dropdowns in GitLab's UI. | -| [Issues](user/project/issues/index.md), including [confidential issues](user/project/issues/confidential_issues.md), [issue and merge request templates](user/project/description_templates.md), and [moving issues](user/project/issues/moving_issues.md) | Project issues, restricting access to issues, create templates for submitting new issues and merge requests, and moving issues between projects. | -| [Labels](user/project/labels.md) | Categorize issues or merge requests with descriptive labels. | -| [Milestones](user/project/milestones/index.md) | Set milestones for delivery of issues and merge requests, with optional due date. | -| [Project Issue Board](user/project/issue_board.md) | Display issues on a Scrum or Kanban board. | -| [Time Tracking](workflow/time_tracking.md) | Track time spent on issues and merge requests. | -| [Todos](workflow/todos.md) | Keep track of work requiring attention with a chronological list displayed on a simple dashboard. | +| Plan Topics | Description | +|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------| +| [Discussions](user/discussions/index.md) | Threads, comments, and resolvable discussions in issues, commits, and merge requests. | +| [Due Dates](user/project/issues/due_dates.md) | Keep track of issue deadlines. | +| [Quick Actions](user/project/quick_actions.md) | Shortcuts for common actions on issues or merge requests, replacing the need to click buttons or use dropdowns in GitLab's UI. | +| [Issues](user/project/issues/index.md), including [confidential issues](user/project/issues/confidential_issues.md),<br/>[issue and merge request templates](user/project/description_templates.md),<br/>and [moving issues](user/project/issues/moving_issues.md) | Project issues, restricting access to issues, create templates for submitting new issues and merge requests, and moving issues between projects. | +| [Labels](user/project/labels.md) | Categorize issues or merge requests with descriptive labels. | +| [Milestones](user/project/milestones/index.md) | Set milestones for delivery of issues and merge requests, with optional due date. | +| [Project Issue Board](user/project/issue_board.md) | Display issues on a Scrum or Kanban board. | +| [Time Tracking](workflow/time_tracking.md) | Track time spent on issues and merge requests. | +| [Todos](workflow/todos.md) | Keep track of work requiring attention with a chronological list displayed on a simple dashboard. | <div align="right"> <a type="button" class="btn btn-default" href="#overview"> @@ -124,16 +130,16 @@ The following documentation relates to the DevOps **Create** stage: #### Projects and Groups -| Create Topics - Projects and Groups | Description | -|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------| -| [Create](gitlab-basics/create-project.md) and [fork](gitlab-basics/fork-project.md) projects, and [import and export projects between instances](user/project/settings/import_export.md) | Create, duplicate, and move projects. | -| [GitLab Pages](user/project/pages/index.md) | Build, test, and deploy your static website with GitLab Pages. | -| [Groups](user/group/index.md) and [Subgroups](user/group/subgroups/index.md) | Organize your projects in groups. | -| [Projects](user/project/index.md), including [project access](public_access/public_access.md) and [settings](user/project/settings/index.md) | Host source code, and control your project's visibility and set configuration. | -| [Search through GitLab](user/search/index.md) | Search for issues, merge requests, projects, groups, and todos. | -| [Snippets](user/snippets.md) | Snippets allow you to create little bits of code. | -| [Web IDE](user/project/web_ide/index.md) | Edit files within GitLab's user interface. | -| [Wikis](user/project/wiki/index.md) | Enhance your repository documentation with built-in wikis. | +| Create Topics - Projects and Groups | Description | +|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------| +| [Create](gitlab-basics/create-project.md) and [fork](gitlab-basics/fork-project.md) projects, and<br/>[import and export<br/>projects between instances](user/project/settings/import_export.md) | Create, duplicate, and move projects. | +| [GitLab Pages](user/project/pages/index.md) | Build, test, and deploy your static website with GitLab Pages. | +| [Groups](user/group/index.md) and [Subgroups](user/group/subgroups/index.md) | Organize your projects in groups. | +| [Projects](user/project/index.md), including [project access](public_access/public_access.md)<br/>and [settings](user/project/settings/index.md) | Host source code, and control your project's visibility and set configuration. | +| [Search through GitLab](user/search/index.md) | Search for issues, merge requests, projects, groups, and todos. | +| [Snippets](user/snippets.md) | Snippets allow you to create little bits of code. | +| [Web IDE](user/project/web_ide/index.md) | Edit files within GitLab's user interface. | +| [Wikis](user/project/wiki/index.md) | Enhance your repository documentation with built-in wikis. | <div align="right"> <a type="button" class="btn btn-default" href="#overview"> @@ -143,18 +149,18 @@ The following documentation relates to the DevOps **Create** stage: #### Repositories -| Create Topics - Repositories | Description | -|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------| -| [Branches](user/project/repository/branches/index.md) and the [default branch](user/project/repository/branches/index.md#default-branch) | How to use branches in GitLab. | -| [Commits](user/project/repository/index.md#commits) and [signing commits](user/project/repository/gpg_signed_commits/index.md) | Work with commits, and use GPG to sign your commits. | -| [Create branches](user/project/repository/web_editor.md#create-a-new-branch), [create](user/project/repository/web_editor.md#create-a-file) and [upload](user/project/repository/web_editor.md#upload-a-file) files, and [create directories](user/project/repository/web_editor.md#create-a-directory) | Create branches, create and upload files, and create directories within GitLab. | -| [Delete merged branches](user/project/repository/branches/index.md#delete-merged-branches) | Bulk delete branches after their changes are merged. | -| [File templates](user/project/repository/web_editor.md#template-dropdowns) | File templates for common files. | -| [Files](user/project/repository/index.md#files) | Files management. | -| [Jupyter Notebook files](user/project/repository/index.md#jupyter-notebook-files) | GitLab's support for `.ipynb` files. | -| [Protected branches](user/project/protected_branches.md) | Use protected branches. | -| [Repositories](user/project/repository/index.md) | Manage source code repositories in GitLab's user interface. | -| [Start a merge request](user/project/repository/web_editor.md#tips) | Start merge request when committing via GitLab's user interface. | +| Create Topics - Repositories | Description | +|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------| +| [Branches](user/project/repository/branches/index.md) and the [default branch](user/project/repository/branches/index.md#default-branch) | How to use branches in GitLab. | +| [Commits](user/project/repository/index.md#commits) and [signing commits](user/project/repository/gpg_signed_commits/index.md) | Work with commits, and use GPG to sign your commits. | +| [Create branches](user/project/repository/web_editor.md#create-a-new-branch), [create](user/project/repository/web_editor.md#create-a-file)<br/>and [upload](user/project/repository/web_editor.md#upload-a-file) files, and [create directories](user/project/repository/web_editor.md#create-a-directory) | Create branches, create and upload files, and create directories within GitLab. | +| [Delete merged branches](user/project/repository/branches/index.md#delete-merged-branches) | Bulk delete branches after their changes are merged. | +| [File templates](user/project/repository/web_editor.md#template-dropdowns) | File templates for common files. | +| [Files](user/project/repository/index.md#files) | Files management. | +| [Jupyter Notebook files](user/project/repository/index.md#jupyter-notebook-files) | GitLab's support for `.ipynb` files. | +| [Protected branches](user/project/protected_branches.md) | Use protected branches. | +| [Repositories](user/project/repository/index.md) | Manage source code repositories in GitLab's user interface. | +| [Start a merge request](user/project/repository/web_editor.md#tips) | Start merge request when committing via GitLab's user interface. | <div align="right"> <a type="button" class="btn btn-default" href="#overview"> @@ -268,15 +274,15 @@ configuration. Then customize everything from buildpacks to CI/CD. The following documentation relates to the DevOps **Configure** stage: -| Configure Topics | Description | -|:-------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------| -| [Auto DevOps](topics/autodevops/index.md) | Automatically employ a complete DevOps lifecycle. | -| [Easy creation of Kubernetes clusters on GKE](user/project/clusters/index.md#adding-and-creating-a-new-gke-cluster-via-gitlab) | Use Google Kubernetes Engine and GitLab. | -| [Executable Runbooks](user/project/clusters/runbooks/index.md) | Documented procedures that explain how to carry out particular processes. | -| [Installing Applications](user/project/clusters/index.md#installing-applications) | Deploy Helm, Ingress, and Prometheus on Kubernetes. | -| [Mattermost slash commands](user/project/integrations/mattermost_slash_commands.md) | Enable and use slash commands from within Mattermost. | -| [Protected variables](ci/variables/README.md#protected-variables) | Restrict variables to protected branches and tags. | -| [Slack slash commands](user/project/integrations/slack_slash_commands.md) | Enable and use slash commands from within Slack. | +| Configure Topics | Description | +|:-----------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------| +| [Auto DevOps](topics/autodevops/index.md) | Automatically employ a complete DevOps lifecycle. | +| [Easy creation of Kubernetes<br/>clusters on GKE](user/project/clusters/index.md#adding-and-creating-a-new-gke-cluster-via-gitlab) | Use Google Kubernetes Engine and GitLab. | +| [Executable Runbooks](user/project/clusters/runbooks/index.md) | Documented procedures that explain how to carry out particular processes. | +| [Installing Applications](user/project/clusters/index.md#installing-applications) | Deploy Helm, Ingress, and Prometheus on Kubernetes. | +| [Mattermost slash commands](user/project/integrations/mattermost_slash_commands.md) | Enable and use slash commands from within Mattermost. | +| [Protected variables](ci/variables/README.md#protected-variables) | Restrict variables to protected branches and tags. | +| [Slack slash commands](user/project/integrations/slack_slash_commands.md) | Enable and use slash commands from within Slack. | <div align="right"> <a type="button" class="btn btn-default" href="#overview"> diff --git a/doc/administration/auth/README.md b/doc/administration/auth/README.md index 373d4239f71..54be7b616cc 100644 --- a/doc/administration/auth/README.md +++ b/doc/administration/auth/README.md @@ -10,7 +10,7 @@ providers. - [LDAP](ldap.md) Includes Active Directory, Apple Open Directory, Open LDAP, and 389 Server - [OmniAuth](../../integration/omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google, - Bitbucket, Facebook, Shibboleth, Crowd, Azure and Authentiq ID + Bitbucket, Facebook, Shibboleth, Crowd, Azure, Authentiq ID, and JWT - [CAS](../../integration/cas.md) Configure GitLab to sign in using CAS - [SAML](../../integration/saml.md) Configure GitLab as a SAML 2.0 Service Provider - [Okta](okta.md) Configure GitLab to sign in using Okta diff --git a/doc/administration/auth/jwt.md b/doc/administration/auth/jwt.md index 8b00f52ffc1..497298503ad 100644 --- a/doc/administration/auth/jwt.md +++ b/doc/administration/auth/jwt.md @@ -26,15 +26,15 @@ JWT will provide you with a secret key for you to use. ```ruby gitlab_rails['omniauth_providers'] = [ { name: 'jwt', - app_secret: 'YOUR_APP_SECRET', args: { - algorithm: 'HS256', - uid_claim: 'email', - required_claims: ["name", "email"], - info_maps: { name: "name", email: "email" }, - auth_url: 'https://example.com/', - valid_within: nil, - } + secret: 'YOUR_APP_SECRET', + algorithm: 'HS256', # Supported algorithms: 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512' + uid_claim: 'email', + required_claims: ['name', 'email'], + info_maps: { name: 'name', email: 'email' }, + auth_url: 'https://example.com/', + valid_within: 3600 # 1 hour + } } ] ``` @@ -43,15 +43,15 @@ JWT will provide you with a secret key for you to use. ``` - { name: 'jwt', - app_secret: 'YOUR_APP_SECRET', args: { - algorithm: 'HS256', - uid_claim: 'email', - required_claims: ["name", "email"], - info_map: { name: "name", email: "email" }, - auth_url: 'https://example.com/', - valid_within: null, - } + secret: 'YOUR_APP_SECRET', + algorithm: 'HS256', # Supported algorithms: 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512' + uid_claim: 'email', + required_claims: ['name', 'email'], + info_map: { name: 'name', email: 'email' }, + auth_url: 'https://example.com/', + valid_within: 3600 # 1 hour + } } ``` @@ -60,7 +60,7 @@ JWT will provide you with a secret key for you to use. 1. Change `YOUR_APP_SECRET` to the client secret and set `auth_url` to your redirect URL. 1. Save the configuration file. -1. [Reconfigure GitLab][] or [restart GitLab][] for the changes to take effect if you +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you installed GitLab via Omnibus or from source respectively. On the sign in page there should now be a JWT icon below the regular sign in form. @@ -68,5 +68,5 @@ Click the icon to begin the authentication process. JWT will ask the user to sign in and authorize the GitLab application. If everything goes well, the user will be redirected to GitLab and will be signed in. -[reconfigure GitLab]: ../restart_gitlab.md#omnibus-gitlab-reconfigure +[reconfigure]: ../restart_gitlab.md#omnibus-gitlab-reconfigure [restart GitLab]: ../restart_gitlab.md#installations-from-source diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md index 3395c503ced..dc6a71e2ebd 100644 --- a/doc/administration/gitaly/index.md +++ b/doc/administration/gitaly/index.md @@ -237,3 +237,14 @@ gitaly_enabled=false When you run `service gitlab restart` Gitaly will be disabled on this particular machine. + +## Troubleshooting Gitaly in production + +Since GitLab 11.6, Gitaly comes with a command-line tool called +`gitaly-debug` that can be run on a Gitaly server to aid in +troubleshooting. In GitLab 11.6 its only sub-command is +`simulate-http-clone` which allows you to measure the maximum possible +Git clone speed for a specific repository on the server. + +For an up to date list of sub-commands see [the gitaly-debug +README](https://gitlab.com/gitlab-org/gitaly/blob/master/cmd/gitaly-debug/README.md). diff --git a/doc/administration/monitoring/performance/request_profiling.md b/doc/administration/monitoring/performance/request_profiling.md index c358dfbead2..dfd9be3d04c 100644 --- a/doc/administration/monitoring/performance/request_profiling.md +++ b/doc/administration/monitoring/performance/request_profiling.md @@ -1,16 +1,17 @@ # Request Profiling ## Procedure + 1. Grab the profiling token from `Monitoring > Requests Profiles` admin page -(highlighted in a blue in the image below). -![Profile token](img/request_profiling_token.png) -1. Pass the header `X-Profile-Token: <token>` to the request you want to profile. You can use any of these tools - * [ModHeader](https://chrome.google.com/webstore/detail/modheader/idgpnmonknjnojddfkpgkljpfnnfcklj) Chrome extension - * [Modify Headers](https://addons.mozilla.org/en-US/firefox/addon/modify-headers/) Firefox extension - * `curl --header 'X-Profile-Token: <token>' https://gitlab.example.com/group/project` + (highlighted in a blue in the image below). + ![Profile token](img/request_profiling_token.png) +1. Pass the header `X-Profile-Token: <token>` to the request you want to profile. You can use: + - Browser extensions. For example, [ModHeader](https://chrome.google.com/webstore/detail/modheader/idgpnmonknjnojddfkpgkljpfnnfcklj) Chrome extension. + - `curl`. For example, `curl --header 'X-Profile-Token: <token>' https://gitlab.example.com/group/project`. 1. Once request is finished (which will take a little longer than usual), you can -view the profiling output from `Monitoring > Requests Profiles` admin page. -![Profiling output](img/request_profile_result.png) + view the profiling output from `Monitoring > Requests Profiles` admin page. + ![Profiling output](img/request_profile_result.png) ## Cleaning up + Profiling output will be cleared out every day via a Sidekiq worker. diff --git a/doc/api/README.md b/doc/api/README.md index 0e8d1aaf86e..b49c3a198f1 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -225,6 +225,42 @@ For more information, refer to the Impersonation tokens are used exactly like regular personal access tokens, and can be passed in either the `private_token` parameter or the `Private-Token` header. +#### Disable impersonation + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/40385) in GitLab +11.6. + +By default, impersonation is enabled. To disable impersonation: + +**For Omnibus installations** + +1. Edit `/etc/gitlab/gitlab.rb`: + + ```ruby + gitlab_rails['impersonation_enabled'] = false + ``` + +1. Save the file and [reconfigure](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure) + GitLab for the changes to take effect. + +To re-enable impersonation, remove this configuration and reconfigure GitLab. + +--- + +**For installations from source** + +1. Edit `config/gitlab.yml`: + + ```yaml + gitlab: + impersonation_enabled: false + ``` + +1. Save the file and [restart](../administration/restart_gitlab.md#installations-from-source) + GitLab for the changes to take effect. + +To re-enable impersonation, remove this configuration and restart GitLab. + ### Sudo NOTE: **Note:** diff --git a/doc/api/commits.md b/doc/api/commits.md index 7d9b52ec24f..6c16216429d 100644 --- a/doc/api/commits.md +++ b/doc/api/commits.md @@ -87,7 +87,7 @@ POST /projects/:id/repository/commits | `action` | string | yes | The action to perform, `create`, `delete`, `move`, `update`, `chmod`| | `file_path` | string | yes | Full path to the file. Ex. `lib/class.rb` | | `previous_path` | string | no | Original full path to the file being moved. Ex. `lib/class1.rb`. Only considered for `move` action. | -| `content` | string | no | File content, required for all except `delete` and `chmod`. Optional for `move` | +| `content` | string | no | File content, required for all except `delete`, `chmod`, and `move`. Move actions that do not specify `content` will preserve the existing file content, and any other value of `content` will overwrite the file content. | | `encoding` | string | no | `text` or `base64`. `text` is default. | | `last_commit_id` | string | no | Last known file commit id. Will be only considered in update, move and delete actions. | | `execute_filemode` | boolean | no | When `true/false` enables/disables the execute flag on the file. Only considered for `chmod` action. | diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index da70c74c4ce..9ff6c73b1b6 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -408,6 +408,7 @@ Parameters: - `merge_request_iid` (required) - The internal ID of the merge request - `render_html` (optional) - If `true` response includes rendered HTML for title and description - `include_diverged_commits_count` (optional) - If `true` response includes the commits behind the target branch +- `include_rebase_in_progress` (optional) - If `true` response includes whether a rebase operation is in progress ```json { @@ -461,6 +462,7 @@ Parameters: }, "merge_when_pipeline_succeeds": true, "merge_status": "can_be_merged", + "merge_error": null, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, "user_notes_count": 1, @@ -505,7 +507,8 @@ Parameters: "head_sha": "2be7ddb704c7b6b83732fdd5b9f09d5a397b5f8f", "start_sha": "c380d3acebd181f13629a25d2e2acca46ffe1e00" }, - "diverged_commits_count": 2 + "diverged_commits_count": 2, + "rebase_in_progress": false } ``` @@ -773,6 +776,7 @@ POST /projects/:id/merge_requests }, "merge_when_pipeline_succeeds": true, "merge_status": "can_be_merged", + "merge_error": null, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, "user_notes_count": 1, @@ -900,6 +904,7 @@ Must include at least one non-required attribute from above. }, "merge_when_pipeline_succeeds": true, "merge_status": "can_be_merged", + "merge_error": null, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, "user_notes_count": 1, @@ -969,10 +974,9 @@ curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://git Merge changes submitted with MR using this API. +If merge request is unable to be accepted (ie: Work in Progress, Closed, Pipeline Pending Completion, or Failed while requiring Success) - you'll get a `405` and the error message 'Method Not Allowed' -If it has some conflicts and can not be merged - you'll get a `405` and the error message 'Branch cannot be merged' - -If merge request is already merged or closed - you'll get a `406` and the error message 'Method Not Allowed' +If it has some conflicts and can not be merged - you'll get a `406` and the error message 'Branch cannot be merged' If the `sha` parameter is passed and does not match the HEAD of the source - you'll get a `409` and the error message 'SHA does not match HEAD of source branch' @@ -1043,6 +1047,7 @@ Parameters: }, "merge_when_pipeline_succeeds": true, "merge_status": "can_be_merged", + "merge_error": null, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, "user_notes_count": 1, @@ -1158,6 +1163,7 @@ Parameters: }, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", + "merge_error": null, "sha": "8888888888888888888888888888888888888888", "merge_commit_sha": null, "user_notes_count": 1, @@ -1206,6 +1212,62 @@ Parameters: } ``` +## Rebase a merge request + +Automatically rebase the `source_branch` of the merge request against its +`target_branch`. + +If you don't have permissions to push to the merge request's source branch - +you'll get a `403 Forbidden` response. + +``` +PUT /projects/:id/merge_requests/:merge_request_iid/rebase +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/76/merge_requests/1/rebase +``` + +This is an asynchronous request. The API will return an empty `202 Accepted` +response if the request is enqueued successfully. + +You can poll the [Get single MR](#get-single-mr) endpoint with the +`include_rebase_in_progress` parameter to check the status of the +asynchronous request. + +If the rebase operation is ongoing, the response will include the following: + +```json +{ + "rebase_in_progress": true + "merge_error": null +} +``` + +Once the rebase operation has completed successfully, the response will include +the following: + +```json +{ + "rebase_in_progress": false, + "merge_error": null, +} +``` + +If the rebase operation fails, the response will include the following: + +```json +{ + "rebase_in_progress": false, + "merge_error": "Rebase failed. Please rebase locally", +} +``` + ## Comments on merge requests Comments are done via the [notes](notes.md) resource. diff --git a/doc/api/repositories.md b/doc/api/repositories.md index c8de7f2191d..55f5a4cc3b2 100644 --- a/doc/api/repositories.md +++ b/doc/api/repositories.md @@ -108,14 +108,18 @@ Get an archive of the repository. This endpoint can be accessed without authentication if the repository is publicly accessible. ``` -GET /projects/:id/repository/archive +GET /projects/:id/repository/archive[.format] ``` +`format` is an optional suffix for the archive format. Default is +`tar.gz`. Options are `tar.gz`, `tar.bz2`, `tbz`, 'tbz2`, `tb2`, +`bz2`, `tar`, and `zip`. For example, specifying `archive.zip` +would send an archive in ZIP format. + Parameters: - `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user - `sha` (optional) - The commit SHA to download. A tag, branch reference or sha can be used. This defaults to the tip of the default branch if not specified -- `format` (optional) - The archive format. Default is `tar.gz`. Options are `tar.gz`, `tar.bz2`, `tbz`, `tbz2`, `tb2`, `bz2`, `tar`, `zip` ## Compare branches, tags or commits diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md index c3624f1a535..5f587f480b6 100644 --- a/doc/api/repository_files.md +++ b/doc/api/repository_files.md @@ -4,6 +4,16 @@ **Create, read, update and delete repository files using this API** +The different scopes available using [personal access tokens](../user/profile/personal_access_tokens.md) are depicted +in the following table. + +| Scope | Description | +| ----- | ----------- | +| `read_repository` | Allows read-access to the repository files. | +| `api` | Allows read-write access to the repository files. | + +> `read_repository` scope was [introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/23534) in GitLab 11.6. + ## Get file from repository Allows you to receive information about file in repository like name, size, diff --git a/doc/api/repository_submodules.md b/doc/api/repository_submodules.md index 2e6797f18f4..11b04c81172 100644 --- a/doc/api/repository_submodules.md +++ b/doc/api/repository_submodules.md @@ -22,7 +22,7 @@ PUT /projects/:id/repository/submodules/:submodule | `commit_message` | string | no | Commit message. If no message is provided, a default one will be set | ```sh -curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/5/repositories/submodules/lib%2Fmodules%2Fexample" +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/5/repository/submodules/lib%2Fmodules%2Fexample" --data "branch=master&commit_sha=3ddec28ea23acc5caa5d8331a6ecb2a65fc03e88&commit_message=Update submodule reference" ``` diff --git a/doc/api/search.md b/doc/api/search.md index 9716f682ace..7e3ae7404a3 100644 --- a/doc/api/search.md +++ b/doc/api/search.md @@ -722,6 +722,23 @@ Example response: ### Scope: wiki_blobs +Filters are available for this scope: + +- filename +- path +- extension + +To use a filter simply include it in your query like: `a query filename:some_name*`. +You may use wildcards (`*`) to use glob matching. + +Wiki blobs searches are performed on both filenames and contents. Search +results: + +- Found in filenames are displayed before results found in contents. +- May contain multiple matches for the same blob because the search string + might be found in both the filename and content, or might appear multiple + times in the content. + ```bash curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=wiki_blobs&search=bye ``` @@ -777,14 +794,21 @@ Example response: ### Scope: blobs Filters are available for this scope: + - filename - path - extension -to use a filter simply include it in your query like so: `a query filename:some_name*`. - +To use a filter simply include it in your query like: `a query filename:some_name*`. You may use wildcards (`*`) to use glob matching. +Blobs searches are performed on both filenames and contents. Search results: + +- Found in filenames are displayed before results found in contents. +- May contain multiple matches for the same blob because the search string + might be found in both the filename and content, or might appear multiple + times in the content. + ```bash curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=blobs&search=installation ``` diff --git a/doc/api/snippets.md b/doc/api/snippets.md index 7892866cd8e..e840e640377 100644 --- a/doc/api/snippets.md +++ b/doc/api/snippets.md @@ -37,13 +37,13 @@ Parameters: | --------- | ---- | -------- | ----------- | | `id` | Integer | yes | The ID of a snippet | -``` bash +```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets/1 ``` Example response: -``` json +```json { "id": 1, "title": "test", @@ -65,6 +65,30 @@ Example response: } ``` +## Single snippet contents + +Get a single snippet's raw contents. + +``` +GET /snippets/:id/raw +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | Integer | yes | The ID of a snippet | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets/1/raw +``` + +Example response: + +``` +Hello World snippet +``` + ## Create new snippet Creates a new snippet. The user must have permission to create new snippets. @@ -84,7 +108,7 @@ Parameters: | `visibility` | String | no | The snippet's visibility | -``` bash +```bash curl --request POST \ --data '{"title": "This is a snippet", "content": "Hello world", "description": "Hello World snippet", "file_name": "test.txt", "visibility": "internal" }' \ --header 'Content-Type: application/json' \ @@ -94,7 +118,7 @@ curl --request POST \ Example response: -``` json +```json { "id": 1, "title": "This is a snippet", @@ -136,7 +160,7 @@ Parameters: | `visibility` | String | no | The snippet's visibility | -``` bash +```bash curl --request PUT \ --data '{"title": "foo", "content": "bar"}' \ --header 'Content-Type: application/json' \ @@ -146,7 +170,7 @@ curl --request PUT \ Example response: -``` json +```json { "id": 1, "title": "test", @@ -201,13 +225,13 @@ GET /snippets/public | `per_page` | Integer | no | number of snippets to return per page | | `page` | Integer | no | the page to retrieve | -``` bash +```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets/public?per_page=2&page=1 ``` Example response: -``` json +```json [ { "author": { diff --git a/doc/ci/README.md b/doc/ci/README.md index dba1f38abe2..4e066a0df97 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -71,6 +71,7 @@ learn how to leverage its potential even more. - [Caching dependencies](caching/index.md) - [Git submodules](git_submodules.md) - How to run your CI jobs when Git submodules are involved +- [Pipelines for merge requests](merge_request_pipelines/index.md) - [Use SSH keys in your build environment](ssh_keys/README.md) - [Trigger pipelines through the GitLab API](triggers/README.md) - [Trigger pipelines on a schedule](../user/project/pipelines/schedules.md) diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index 31649ee2792..959271d8abc 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -67,6 +67,9 @@ services you need to `.gitlab-ci.yml` or manually modify `config.toml`. Any image found at [Docker Hub][hub] or your private Container Registry can be used as a service. +Services inherit the same DNS servers, search domains, and additional hosts as +the CI container itself. + You can see some widely used services examples in the relevant documentation of [CI services examples](../services/README.md). diff --git a/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md b/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md index b090ea014dc..b1ccce744d8 100644 --- a/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md +++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md @@ -273,6 +273,8 @@ The `releases` directory will hold all our deployments: echo 'Cloning repository' [ -d {{ $releases_dir }} ] || mkdir {{ $releases_dir }} git clone --depth 1 {{ $repository }} {{ $new_release_dir }} + cd {{ $releases_dir }} + git reset --hard {{ $commit }} @endtask ... @@ -349,6 +351,8 @@ At the end, our `Envoy.blade.php` file will look like this: echo 'Cloning repository' [ -d {{ $releases_dir }} ] || mkdir {{ $releases_dir }} git clone --depth 1 {{ $repository }} {{ $new_release_dir }} + cd {{ $releases_dir }} + git reset --hard {{ $commit }} @endtask @task('run_composer') @@ -519,7 +523,7 @@ deploy_production: - mkdir -p ~/.ssh - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config' - - ~/.composer/vendor/bin/envoy run deploy + - ~/.composer/vendor/bin/envoy run deploy --commit="$CI_COMMIT_SHA" environment: name: production url: http://192.168.1.1 diff --git a/doc/ci/interactive_web_terminal/index.md b/doc/ci/interactive_web_terminal/index.md index 1ddc1bf4d7e..2c799e83a5f 100644 --- a/doc/ci/interactive_web_terminal/index.md +++ b/doc/ci/interactive_web_terminal/index.md @@ -1,4 +1,4 @@ -# Getting started with interactive web terminals +# Getting started with interactive web terminals **[CORE ONLY]** > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/50144) in GitLab 11.3. @@ -6,10 +6,9 @@ Interactive web terminals give the user access to a terminal in GitLab for running one-off commands for their CI pipeline. NOTE: **Note:** -This is not available for the shared Runners on GitLab.com. -To make use of this feature, you need to provide your -[own Runner](https://docs.gitlab.com/runner/install/) and properly -[configure it](#configuration). +GitLab.com does not support interactive web terminal at the moment. Please +follow [this issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/52611) for +progress. ## Configuration diff --git a/doc/ci/merge_request_pipelines/img/merge_request.png b/doc/ci/merge_request_pipelines/img/merge_request.png Binary files differnew file mode 100644 index 00000000000..1fe2eec2008 --- /dev/null +++ b/doc/ci/merge_request_pipelines/img/merge_request.png diff --git a/doc/ci/merge_request_pipelines/img/pipeline_detail.png b/doc/ci/merge_request_pipelines/img/pipeline_detail.png Binary files differnew file mode 100644 index 00000000000..def1781dd75 --- /dev/null +++ b/doc/ci/merge_request_pipelines/img/pipeline_detail.png diff --git a/doc/ci/merge_request_pipelines/index.md b/doc/ci/merge_request_pipelines/index.md new file mode 100644 index 00000000000..706e83abf44 --- /dev/null +++ b/doc/ci/merge_request_pipelines/index.md @@ -0,0 +1,84 @@ +# Pipelines for merge requests + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/15310) in GitLab 11.6 + +Usually, when a developer creates a new merge request, a pipeline runs on the +new change and checks if it's qualified to be merged into a target branch. This +pipeline should contain only necessary jobs for checking the new changes. +For example, unit tests, lint checks, and Review Apps are often used in this cycle. + +With pipelines for merge requests, you can design a specific pipeline structure +for merge requests. All you need to do is just adding `only: [merge_requests]` to +the jobs that you want it to run for only merge requests. +Every time, when developers create or update merge requests, a pipeline runs on +their new commits at every push to GitLab. + +NOTE: **Note**: +If you use both this feature and the [Merge When Pipeline Succeeds](../../user/project/merge_requests/merge_when_pipeline_succeeds.md) +feature, pipelines for merge requests take precendence over the other regular pipelines. + +For example, consider a GitLab CI/CD configuration in .gitlab-ci.yml as follows: + +```yaml +build: + stage: build + script: ./build + only: + - branches + - tags + - merge_requests + +test: + stage: test + script: ./test + only: + - merge_requests + +deploy: + stage: deploy + script: ./deploy +``` + +After a developer updated code in a merge request with whatever methods (e.g. `git push`), +GitLab detects that the code is updated and create a new pipeline for the merge request. +The pipeline fetches the latest code from the source branch and run tests against it. +In this example, the pipeline contains only `build` and `test` jobs. +Since `deploy` job does not have the `only: [merge_requests]` rule, +deployment jobs will not happen in the merge request. + +Consider this pipeline list viewed from the **Pipelines** tab in a merge request: + +![Merge request page](img/merge_request.png) + +Note that pipelines tagged as **merge request** indicate that they were triggered +when a merge request was created or updated. + +The same tag is shown on the pipeline's details: + +![Pipeline's details](img/pipeline_detail.png) + +## Important notes about merge requests from forked projects + +Note that the current behavior is subject to change. In the usual contribution +flow, external contributors follow the following steps: + +1. Fork a parent project. +1. Create a merge request from the forked project that targets the `master` branch +in the parent project. +1. A pipeline runs on the merge request. +1. A mainatiner from the parent project checks the pipeline result, and merge +into a target branch if the latest pipeline has passed. + +Currently, those pipelines are created in a **forked** project, not in the +parent project. This means you cannot completely trust the pipeline result, +because, technically, external contributors can disguise their pipeline results +by tweaking their GitLab Runner in the forked project. + +There are multiple reasons about why GitLab doesn't allow those pipelines to be +created in the parent project, but one of the biggest reasons is security concern. +External users could steal secret variables from the parent project by modifying +.gitlab-ci.yml, which could be some sort of credentials. This should not happen. + +We're discussing a secure solution of running pipelines for merge requests +that submitted from forked projects, +see [the issue about the permission extension](https://gitlab.com/gitlab-org/gitlab-ce/issues/23902). diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index bdbcf8c9435..87799be8ab4 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -40,75 +40,86 @@ Starting with GitLab 9.0, we have deprecated some variables. Read the strongly advised to use the new variables as we will remove the old ones in future GitLab releases.** -| Variable | GitLab | Runner | Description | -|-------------------------------- |--------|--------|-------------| -| **ARTIFACT_DOWNLOAD_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to download artifacts running a job | -| **CI** | all | 0.4 | Mark that job is executed in CI environment | -| **CI_COMMIT_REF_NAME** | 9.0 | all | The branch or tag name for which project is built | -| **CI_COMMIT_REF_SLUG** | 9.0 | all | `$CI_COMMIT_REF_NAME` lowercased, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. No leading / trailing `-`. Use in URLs, host names and domain names. | -| **CI_COMMIT_SHA** | 9.0 | all | The commit revision for which project is built | -| **CI_COMMIT_BEFORE_SHA** | 11.2 | all | The previous latest commit present on a branch before a push request. | -| **CI_COMMIT_TAG** | 9.0 | 0.5 | The commit tag name. Present only when building tags. | -| **CI_COMMIT_MESSAGE** | 10.8 | all | The full commit message. | -| **CI_COMMIT_TITLE** | 10.8 | all | The title of the commit - the full first line of the message | -| **CI_COMMIT_DESCRIPTION** | 10.8 | all | The description of the commit: the message without first line, if the title is shorter than 100 characters; full message in other case. | -| **CI_CONFIG_PATH** | 9.4 | 0.5 | The path to CI config file. Defaults to `.gitlab-ci.yml` | -| **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled | -| **CI_DEPLOY_USER** | 10.8 | all | Authentication username of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| -| **CI_DEPLOY_PASSWORD** | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| -| **CI_DISPOSABLE_ENVIRONMENT** | all | 10.1 | Marks that the job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. | -| **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job | -| **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. | -| **CI_ENVIRONMENT_URL** | 9.3 | all | The URL of the environment for this job | -| **CI_JOB_ID** | 9.0 | all | The unique id of the current job that GitLab CI uses internally | -| **CI_JOB_MANUAL** | 8.12 | all | The flag to indicate that job was manually started | -| **CI_JOB_NAME** | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` | -| **CI_JOB_STAGE** | 9.0 | 0.5 | The name of the stage as defined in `.gitlab-ci.yml` | -| **CI_JOB_TOKEN** | 9.0 | 1.2 | Token used for authenticating with the [GitLab Container Registry][registry] and downloading [dependent repositories][dependent-repositories] | -| **CI_NODE_INDEX** | 11.5 | all | Index of the job in the job set. If the job is not parallelized, this variable is not set. | -| **CI_NODE_TOTAL** | 11.5 | all | Total number of instances of this job running in parallel. If the job is not parallelized, this variable is set to `1`. | -| **CI_JOB_URL** | 11.1 | 0.5 | Job details URL | -| **CI_REPOSITORY_URL** | 9.0 | all | The URL to clone the Git repository | -| **CI_RUNNER_DESCRIPTION** | 8.10 | 0.5 | The description of the runner as saved in GitLab | -| **CI_RUNNER_ID** | 8.10 | 0.5 | The unique id of runner being used | -| **CI_RUNNER_TAGS** | 8.10 | 0.5 | The defined runner tags | -| **CI_RUNNER_VERSION** | all | 10.6 | GitLab Runner version that is executing the current job | -| **CI_RUNNER_REVISION** | all | 10.6 | GitLab Runner revision that is executing the current job | -| **CI_RUNNER_EXECUTABLE_ARCH** | all | 10.6 | The OS/architecture of the GitLab Runner executable (note that this is not necessarily the same as the environment of the executor) | -| **CI_PIPELINE_ID** | 8.10 | 0.5 | The unique id of the current pipeline that GitLab CI uses internally | -| **CI_PIPELINE_IID** | 11.0 | all | The unique id of the current pipeline scoped to project | -| **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] | -| **CI_PIPELINE_SOURCE** | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `trigger`, `schedule`, `api`, and `pipeline`. For pipelines created before GitLab 9.5, this will show as `unknown` | -| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run | -| **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally | -| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) | -| **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built | -| **CI_PROJECT_PATH** | 8.10 | 0.5 | The namespace with project name | -| **CI_PROJECT_PATH_SLUG** | 9.3 | all | `$CI_PROJECT_PATH` lowercased and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. | -| **CI_PIPELINE_URL** | 11.1 | 0.5 | Pipeline details URL | -| **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project | -| **CI_PROJECT_VISIBILITY** | 10.3 | all | The project visibility (internal, private, public) | -| **CI_REGISTRY** | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry | -| **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project | -| **CI_REGISTRY_PASSWORD** | 9.0 | all | The password to use to push containers to the GitLab Container Registry | -| **CI_REGISTRY_USER** | 9.0 | all | The username to use to push containers to the GitLab Container Registry | -| **CI_SERVER** | all | all | Mark that job is executed in CI environment | -| **CI_SERVER_NAME** | all | all | The name of CI server that is used to coordinate jobs | -| **CI_SERVER_REVISION** | all | all | GitLab revision that is used to schedule jobs | -| **CI_SERVER_VERSION** | all | all | GitLab version that is used to schedule jobs | -| **CI_SERVER_VERSION_MAJOR** | 11.4 | all | GitLab version major component | -| **CI_SERVER_VERSION_MINOR** | 11.4 | all | GitLab version minor component | -| **CI_SERVER_VERSION_PATCH** | 11.4 | all | GitLab version patch component | -| **CI_SHARED_ENVIRONMENT** | all | 10.1 | Marks that the job is executed in a shared environment (something that is persisted across CI invocations like `shell` or `ssh` executor). If the environment is shared, it is set to true, otherwise it is not defined at all. | -| **GET_SOURCES_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to fetch sources running a job | -| **GITLAB_CI** | all | all | Mark that job is executed in GitLab CI environment | -| **GITLAB_USER_EMAIL** | 8.12 | all | The email of the user who started the job | -| **GITLAB_USER_ID** | 8.12 | all | The id of the user who started the job | -| **GITLAB_USER_LOGIN** | 10.0 | all | The login username of the user who started the job | -| **GITLAB_USER_NAME** | 10.0 | all | The real name of the user who started the job | -| **RESTORE_CACHE_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to restore the cache running a job | - -## 9.0 Renaming +| Variable | GitLab | Runner | Description | +|-------------------------------------------|--------|--------|-------------| +| **ARTIFACT_DOWNLOAD_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to download artifacts running a job | +| **CI** | all | 0.4 | Mark that job is executed in CI environment | +| **CI_COMMIT_REF_NAME** | 9.0 | all | The branch or tag name for which project is built | +| **CI_COMMIT_REF_SLUG** | 9.0 | all | `$CI_COMMIT_REF_NAME` lowercased, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. No leading / trailing `-`. Use in URLs, host names and domain names. | +| **CI_COMMIT_SHA** | 9.0 | all | The commit revision for which project is built | +| **CI_COMMIT_BEFORE_SHA** | 11.2 | all | The previous latest commit present on a branch before a push request. | +| **CI_COMMIT_TAG** | 9.0 | 0.5 | The commit tag name. Present only when building tags. | +| **CI_COMMIT_MESSAGE** | 10.8 | all | The full commit message. | +| **CI_COMMIT_TITLE** | 10.8 | all | The title of the commit - the full first line of the message | +| **CI_COMMIT_DESCRIPTION** | 10.8 | all | The description of the commit: the message without first line, if the title is shorter than 100 characters; full message in other case. | +| **CI_CONFIG_PATH** | 9.4 | 0.5 | The path to CI config file. Defaults to `.gitlab-ci.yml` | +| **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled | +| **CI_DEPLOY_USER** | 10.8 | all | Authentication username of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| +| **CI_DEPLOY_PASSWORD** | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| +| **CI_DISPOSABLE_ENVIRONMENT** | all | 10.1 | Marks that the job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. | +| **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job | +| **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. | +| **CI_ENVIRONMENT_URL** | 9.3 | all | The URL of the environment for this job | +| **CI_JOB_ID** | 9.0 | all | The unique id of the current job that GitLab CI uses internally | +| **CI_JOB_MANUAL** | 8.12 | all | The flag to indicate that job was manually started | +| **CI_JOB_NAME** | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` | +| **CI_JOB_STAGE** | 9.0 | 0.5 | The name of the stage as defined in `.gitlab-ci.yml` | +| **CI_JOB_TOKEN** | 9.0 | 1.2 | Token used for authenticating with the [GitLab Container Registry][registry] and downloading [dependent repositories][dependent-repositories] | +| **CI_MERGE_REQUEST_ID** | 11.6 | all | The ID of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md) | +| **CI_MERGE_REQUEST_IID** | 11.6 | all | The IID of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md) | +| **CI_MERGE_REQUEST_REF_PATH** | 11.6 | all | The ref path of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md). (e.g. `refs/merge-requests/1/head`) | +| **CI_MERGE_REQUEST_PROJECT_ID** | 11.6 | all | The ID of the project of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md) | +| **CI_MERGE_REQUEST_PROJECT_PATH** | 11.6 | all | The path of the project of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md) (e.g. `namespace/awesome-project`) | +| **CI_MERGE_REQUEST_PROJECT_URL** | 11.6 | all | The URL of the project of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md) (e.g. `http://192.168.10.15:3000/namespace/awesome-project`) | +| **CI_MERGE_REQUEST_TARGET_BRANCH_NAME** | 11.6 | all | The target branch name of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md) | +| **CI_MERGE_REQUEST_SOURCE_PROJECT_ID** | 11.6 | all | The ID of the source project of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md) | +| **CI_MERGE_REQUEST_SOURCE_PROJECT_PATH** | 11.6 | all | The path of the source project of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md) | +| **CI_MERGE_REQUEST_SOURCE_PROJECT_URL** | 11.6 | all | The URL of the source project of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md) | +| **CI_MERGE_REQUEST_SOURCE_BRANCH_NAME** | 11.6 | all | The source branch name of the merge request if it's [pipelines for merge requests](../merge_request_pipelines/index.md) | +| **CI_NODE_INDEX** | 11.5 | all | Index of the job in the job set. If the job is not parallelized, this variable is not set. | +| **CI_NODE_TOTAL** | 11.5 | all | Total number of instances of this job running in parallel. If the job is not parallelized, this variable is set to `1`. | +| **CI_JOB_URL** | 11.1 | 0.5 | Job details URL | +| **CI_REPOSITORY_URL** | 9.0 | all | The URL to clone the Git repository | +| **CI_RUNNER_DESCRIPTION** | 8.10 | 0.5 | The description of the runner as saved in GitLab | +| **CI_RUNNER_ID** | 8.10 | 0.5 | The unique id of runner being used | +| **CI_RUNNER_TAGS** | 8.10 | 0.5 | The defined runner tags | +| **CI_RUNNER_VERSION** | all | 10.6 | GitLab Runner version that is executing the current job | +| **CI_RUNNER_REVISION** | all | 10.6 | GitLab Runner revision that is executing the current job | +| **CI_RUNNER_EXECUTABLE_ARCH** | all | 10.6 | The OS/architecture of the GitLab Runner executable (note that this is not necessarily the same as the environment of the executor) | +| **CI_PIPELINE_ID** | 8.10 | 0.5 | The unique id of the current pipeline that GitLab CI uses internally | +| **CI_PIPELINE_IID** | 11.0 | all | The unique id of the current pipeline scoped to project | +| **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] | +| **CI_PIPELINE_SOURCE** | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `trigger`, `schedule`, `api`, and `pipeline`. For pipelines created before GitLab 9.5, this will show as `unknown` | +| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run | +| **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally | +| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) | +| **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built | +| **CI_PROJECT_PATH** | 8.10 | 0.5 | The namespace with project name | +| **CI_PROJECT_PATH_SLUG** | 9.3 | all | `$CI_PROJECT_PATH` lowercased and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. | +| **CI_PIPELINE_URL** | 11.1 | 0.5 | Pipeline details URL | +| **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project | +| **CI_PROJECT_VISIBILITY** | 10.3 | all | The project visibility (internal, private, public) | +| **CI_REGISTRY** | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry | +| **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project | +| **CI_REGISTRY_PASSWORD** | 9.0 | all | The password to use to push containers to the GitLab Container Registry | +| **CI_REGISTRY_USER** | 9.0 | all | The username to use to push containers to the GitLab Container Registry | +| **CI_SERVER** | all | all | Mark that job is executed in CI environment | +| **CI_SERVER_NAME** | all | all | The name of CI server that is used to coordinate jobs | +| **CI_SERVER_REVISION** | all | all | GitLab revision that is used to schedule jobs | +| **CI_SERVER_VERSION** | all | all | GitLab version that is used to schedule jobs | +| **CI_SERVER_VERSION_MAJOR** | 11.4 | all | GitLab version major component | +| **CI_SERVER_VERSION_MINOR** | 11.4 | all | GitLab version minor component | +| **CI_SERVER_VERSION_PATCH** | 11.4 | all | GitLab version patch component | +| **CI_SHARED_ENVIRONMENT** | all | 10.1 | Marks that the job is executed in a shared environment (something that is persisted across CI invocations like `shell` or `ssh` executor). If the environment is shared, it is set to true, otherwise it is not defined at all. | +| **GET_SOURCES_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to fetch sources running a job | +| **GITLAB_CI** | all | all | Mark that job is executed in GitLab CI environment | +| **GITLAB_USER_EMAIL** | 8.12 | all | The email of the user who started the job | +| **GITLAB_USER_ID** | 8.12 | all | The id of the user who started the job | +| **GITLAB_USER_LOGIN** | 10.0 | all | The login username of the user who started the job | +| **GITLAB_USER_NAME** | 10.0 | all | The real name of the user who started the job | +| **RESTORE_CACHE_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to restore the cache running a job | + +## GitLab 9.0 renaming To follow conventions of naming across GitLab, and to further move away from the `build` term and toward `job` CI variables have been renamed for the 9.0 @@ -137,7 +148,7 @@ future GitLab releases.** ## `.gitlab-ci.yml` defined variables NOTE **Note:** -This feature requires GitLab Runner 0.5.0 or higher and GitLab CI 7.14 or higher. +This feature requires GitLab Runner 0.5.0 or higher and GitLab 7.14 or higher. GitLab CI allows you to add to `.gitlab-ci.yml` variables that are set in the build environment. The variables are hence saved in the repository, and they @@ -176,8 +187,7 @@ script: ## Variables -NOTE: **Note:** -Group-level variables were added in GitLab 9.4. +> Group-level variables were introduced in GitLab 9.4. CAUTION: **Important:** Be aware that variables are not masked, and their values can be shown @@ -206,8 +216,7 @@ Once you set them, they will be available for all subsequent pipelines. You can ### Protected variables ->**Notes:** -This feature requires GitLab 9.3 or higher. +> Introduced in GitLab 9.3. Variables could be protected. Whenever a variable is protected, it would only be securely passed to pipelines running on the @@ -228,8 +237,7 @@ Variables can be specified for a single pipeline run when a [manual pipeline](.. ## Deployment variables -NOTE: **Note:** -This feature requires GitLab CI 8.15 or higher. +> Introduced in GitLab 8.15. [Project services](../../user/project/integrations/project_services.md) that are responsible for deployment configuration may define their own variables that @@ -490,7 +498,7 @@ export CI_REGISTRY_PASSWORD="longalfanumstring" ## Variables expressions -> Variables expressions were added in GitLab 10.7. +> Introduced 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 jobs are going to diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 77406493578..1277d1fdf8b 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -342,15 +342,16 @@ In addition, `only` and `except` allow the use of special keywords: | **Value** | **Description** | | --------- | ---------------- | -| `branches` | When a branch is pushed. | -| `tags` | When a tag is pushed. | -| `api` | When pipeline has been triggered by a second pipelines API (not triggers API). | -| `external` | When using CI services other than GitLab. | -| `pipelines` | For multi-project triggers, created using the API with `CI_JOB_TOKEN`. | -| `pushes` | Pipeline is triggered by a `git push` by the user. | -| `schedules` | For [scheduled pipelines][schedules]. | -| `triggers` | For pipelines created using a trigger token. | -| `web` | For pipelines created using **Run pipeline** button in GitLab UI (under your project's **Pipelines**). | +| `branches` | When a git reference of a pipeline is a branch. | +| `tags` | When a git reference of a pipeline is a tag. | +| `api` | When pipeline has been triggered by a second pipelines API (not triggers API). | +| `external` | When using CI services other than GitLab. | +| `pipelines` | For multi-project triggers, created using the API with `CI_JOB_TOKEN`. | +| `pushes` | Pipeline is triggered by a `git push` by the user. | +| `schedules` | For [scheduled pipelines][schedules]. | +| `triggers` | For pipelines created using a trigger token. | +| `web` | For pipelines created using **Run pipeline** button in GitLab UI (under your project's **Pipelines**). | +| `merge_requests` | When a merge request is created or updated (See [pipelines for merge requests](../merge_request_pipelines/index.md)). | In the example below, `job` will run only for refs that start with `issue-`, whereas all branches will be skipped: @@ -391,6 +392,24 @@ job: The above example will run `job` for all branches on `gitlab-org/gitlab-ce`, except master. +If a job does not have neither `only` nor `except` rule, +`only: ['branches', 'tags']` is set by default. + +For example, + +```yaml +job: + script: echo 'test' +``` + +is translated to + +```yaml +job: + script: echo 'test' + only: ['branches', 'tags'] +``` + ## `only` and `except` (complex) > `refs` and `kubernetes` policies introduced in GitLab 10.0 @@ -1590,7 +1609,7 @@ Possible values for `when` are: > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22631) in GitLab 11.5. `parallel` allows you to configure how many instances of a job to run in -parallel. This value has to be greater than or equal to two (2) and less or equal than 50. +parallel. This value has to be greater than or equal to two (2) and less than or equal to 50. This creates N instances of the same job that run in parallel. They're named sequentially from `job_name 1/N` to `job_name N/N`. @@ -1821,13 +1840,6 @@ These variables can be later used in all executed commands and scripts. The YAML-defined variables are also set to all created service containers, thus allowing to fine tune them. -To turn off global defined variables in a specific job, define an empty hash: - -```yaml -job_name: - variables: {} -``` - Except for the user defined variables, there are also the ones [set up by the Runner itself](../variables/README.md#predefined-variables-environment-variables). One example would be `CI_COMMIT_REF_NAME` which has the value of diff --git a/doc/development/architecture.md b/doc/development/architecture.md index 931a7a8e6d5..e65c5f05505 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -55,7 +55,7 @@ GitLab can be considered to have two layers from a process perspective: ### gitaly -- [Omnibus confiugration options](https://gitlab.com/gitlab-org/gitaly/tree/master/doc/configuration) +- [Omnibus configuration options](https://gitlab.com/gitlab-org/gitaly/tree/master/doc/configuration) - Layer: Core Service (Data) Gitaly is a service designed by GitLab to remove our need for NFS for Git storage in distributed deployments of GitLab. (Think GitLab.com or High Availablity Deployments) As of 11.3.0, This service handles all Git level access in GitLab. You can read more about the project [in the project's readme](https://gitlab.com/gitlab-org/gitaly). diff --git a/doc/development/automatic_ce_ee_merge.md b/doc/development/automatic_ce_ee_merge.md index 58e08d432cc..e4eb26b3aca 100644 --- a/doc/development/automatic_ce_ee_merge.md +++ b/doc/development/automatic_ce_ee_merge.md @@ -1,57 +1,33 @@ # Automatic CE->EE merge -GitLab Community Edition is merged automatically every 3 hours into the -Enterprise Edition (look for the [`CE Upstream` merge requests]). - -This merge is done automatically in a -[scheduled pipeline](https://gitlab.com/gitlab-org/release-tools/-/jobs/43201679). - -## What to do if you are pinged in a `CE Upstream` merge request to resolve a conflict? - -1. Please resolve the conflict as soon as possible or ask someone else to do it - - It's ok to resolve more conflicts than the one that you are asked to resolve. - In that case, it's a good habit to ask for a double-check on your resolution - by someone who is familiar with the code you touched. -1. Once you have resolved your conflicts, push to the branch (no force-push) -1. Assign the merge request to the next person that has to resolve a conflict -1. If all conflicts are resolved after your resolution is pushed, keep the merge - request assigned to you: **you are now responsible for the merge request to be - green** -1. If you are the last person to resolve the conflicts, the pipeline is green, - and you have merge rights, merge the MR, but **do not** choose to squash. - Otherwise, assign the MR to someone that can merge. -1. If you need any help, you can ping the current [release managers], or ask in - the `#ce-to-ee` Slack channel - -A few notes about the automatic CE->EE merge job: - -- If a merge is already in progress, the job - [doesn't create a new one](https://gitlab.com/gitlab-org/release-tools/-/jobs/43157687). -- If there is nothing to merge (i.e. EE is up-to-date with CE), the job doesn't - create a new one -- The job posts messages to the `#ce-to-ee` Slack channel to inform what's the - current CE->EE merge status (e.g. "A new MR has been created", "A MR is still pending") - -[`CE Upstream` merge requests]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests?label_name%5B%5D=CE+upstream -[release managers]: https://about.gitlab.com/release-managers/ +Whenever a commit is pushed to the CE `master` branch, it is automatically +merged into the EE `master` branch. If the commit produces any conflicts, it is +instead reverted from CE `master`. When this happens, a merge request will be +set up automatically that can be used to reinstate the changes. This merge +request will be assigned to the author of the conflicting commit, or the merge +request author if the commit author could not be associated with a GitLab user. +If no author could be found, the merge request is assigned to a random member of +the Delivery team. It is then up to this team member to figure out who to assign +the merge request to. + +Because some commits can not be reverted if new commits depend on them, we also +run a job periodically that processes a range of commits and tries to merge or +revert them. This should ensure that all commits are either merged into EE +`master`, or reverted, instead of just being left behind in CE. ## Always merge EE merge requests before their CE counterparts **In order to avoid conflicts in the CE->EE merge, you should always merge the EE version of your CE merge request first, if present.** -The rationale for this is that as CE->EE merges are done automatically every few -hours, it can happen that: +The rationale for this is that as CE->EE merges are done automatically, it can +happen that: -1. A CE merge request that needs EE-specific changes is merged -1. The automatic CE->EE merge happens +1. A CE merge request that needs EE-specific changes is merged. +1. The automatic CE->EE merge happens. 1. Conflicts due to the CE merge request occur since its EE merge request isn't - merged yet -1. The automatic merge bot will ping someone to resolve the conflict **that are - already resolved in the EE merge request that isn't merged yet** - -That's a waste of time, and that's why you should merge EE merge request before -their CE counterpart. + merged yet. +1. The CE changes are reverted. ## Avoiding CE->EE merge conflicts beforehand @@ -69,136 +45,89 @@ detect if the current branch's changes will conflict during the CE->EE merge. The job reports what files are conflicting and how to set up a merge request against EE. -#### How the job works - -1. Generates the diff between your branch and current CE `master` -1. Tries to apply it to current EE `master` -1. If it applies cleanly, the job succeeds, otherwise... -1. Detects a branch with the `ee-` prefix or `-ee` suffix in EE -1. If it exists, generate the diff between this branch and current EE `master` -1. Tries to apply it to current EE `master` -1. If it applies cleanly, the job succeeds - -In the case where the job fails, it means you should create an `ee-<ce_branch>` -or `<ce_branch>-ee` branch, push it to EE and open a merge request against EE -`master`. -At this point if you retry the failing job in your CE merge request, it should -now pass. - -Notes: - -- This task is not a silver-bullet, its current goal is to bring awareness to - developers that their work needs to be ported to EE. -- Community contributors shouldn't be required to submit merge requests against - EE, but reviewers should take actions by either creating such EE merge request - or asking a GitLab developer to do it **before the merge request is merged**. -- If you branch is too far behind `master`, the job will fail. In that case you - should rebase your branch upon latest `master`. -- Code reviews for merge requests often consist of multiple iterations of - feedback and fixes. There is no need to update your EE MR after each - iteration. Instead, create an EE MR as soon as you see the - `ee_compat_check` job failing. After you receive the final approval - from a Maintainer (but **before the CE MR is merged**) update the EE MR. - This helps to identify significant conflicts sooner, but also reduces the - number of times you have to resolve conflicts. -- Please remember to - [always have your EE merge request merged before the CE version](#always-merge-ee-merge-requests-before-their-ce-counterparts). -- You can use [`git rerere`](https://git-scm.com/docs/git-rerere) - to avoid resolving the same conflicts multiple times. - -### Cherry-picking from CE to EE - -For avoiding merge conflicts, we use a method of creating equivalent branches -for CE and EE. If the `ee-compat-check` job fails, this process is required. - -This method only requires that you have cloned both CE and EE into your computer. -If you don't have them yet, please go ahead and clone them: - -- Clone CE repo: `git clone git@gitlab.com:gitlab-org/gitlab-ce.git` -- Clone EE repo: `git clone git@gitlab.com:gitlab-org/gitlab-ee.git` - -And the only additional setup we need is to add CE as remote of EE and vice-versa: - -- Open two terminal windows, one in CE, and another one in EE: - - In EE: `git remote add ce git@gitlab.com:gitlab-org/gitlab-ce.git` - - In CE: `git remote add ee git@gitlab.com:gitlab-org/gitlab-ee.git` - -That's all setup we need, so that we can cherry-pick a commit from CE to EE, and -from EE to CE. - -Now, every time you create an MR for CE and EE: - -1. Open two terminal windows, one in CE, and another one in EE -1. In the CE terminal: - 1. Create the CE branch, e.g., `branch-example` - 1. Make your changes and push a commit (commit A) - 1. Create the CE merge request in GitLab -1. In the EE terminal: - 1. Create the EE-equivalent branch ending with `-ee`, e.g., - `git checkout -b branch-example-ee` - 1. Fetch the CE branch: `git fetch ce branch-example` - 1. Cherry-pick the commit A: `git cherry-pick commit-A-SHA` - 1. If Git prompts you to fix the conflicts, do a `git status` - to check which files contain conflicts, fix them, save the files - 1. Add the changes with `git add .` but **DO NOT commit** them - 1. Continue cherry-picking: `git cherry-pick --continue` - 1. Push to EE: `git push origin branch-example-ee` -1. Create the EE-equivalent MR and link to the CE MR from the -description "Ports [CE-MR-LINK] to EE" -1. Once all the jobs are passing in both CE and EE, you've addressed the -feedback from your own team, and got them approved, the merge requests can be merged. -1. When both MRs are ready, the EE merge request will be merged first, and the -CE-equivalent will be merged next. - -**Important notes:** - -- The commit SHA can be easily found from the GitLab UI. From a merge request, -open the tab **Commits** and click the copy icon to copy the commit SHA. -- To cherry-pick a **commit range**, such as [A > B > C > D] use: - - ```shell - git cherry-pick "oldest-commit-SHA^..newest-commit-SHA" - ``` - - For example, suppose the commit A is the oldest, and its SHA is `4f5e4018c09ed797fdf446b3752f82e46f5af502`, - and the commit D is the newest, and its SHA is `80e1c9e56783bd57bd7129828ec20b252ebc0538`. - The cherry-pick command will be: - - ```shell - git cherry-pick "4f5e4018c09ed797fdf446b3752f82e46f5af502^..80e1c9e56783bd57bd7129828ec20b252ebc0538" - ``` - -- To cherry-pick a **merge commit**, use the flag `-m 1`. For example, suppose that the -merge commit SHA is `138f5e2f20289bb376caffa0303adb0cac859ce1`: - - ```shell - git cherry-pick -m 1 138f5e2f20289bb376caffa0303adb0cac859ce1 - ``` -- To cherry-pick multiple commits, such as B and D in a range [A > B > C > D], use: - - ```shell - git cherry-pick commmit-B-SHA commit-D-SHA - ``` - - For example, suppose commit B SHA = `4f5e4018c09ed797fdf446b3752f82e46f5af502`, - and the commit D SHA = `80e1c9e56783bd57bd7129828ec20b252ebc0538`. - The cherry-pick command will be: - - ```shell - git cherry-pick 4f5e4018c09ed797fdf446b3752f82e46f5af502 80e1c9e56783bd57bd7129828ec20b252ebc0538 - ``` - - This case is particularly useful when you have a merge commit in a sequence of - commits and you want to cherry-pick all but the merge commit. - -- If you push more commits to the CE branch, you can safely repeat the procedure -to cherry-pick them to the EE-equivalent branch. You can do that as many times as -necessary, using the same CE and EE branches. -- If you submitted the merge request to the CE repo and the `ee-compat-check` job passed, -you are not required to submit the EE-equivalent MR, but it's still recommended. If the -job failed, you are required to submit the EE MR so that you can fix the conflicts in EE -before merging your changes into CE. - ---- - -[Return to Development documentation](README.md) +## How to reinstate changes + +When a commit is reverted, the corresponding merge request to reinstate the +changes will include all the details necessary to ensure the changes make it +back into CE and EE. However, you still need to manually set up an EE merge +request that resolves the conflicts. + +Each merge request used to reinstate changes will have the "reverted" label +applied. Please do not remove this label, as it will be used to determine how +many times commits are reverted and how long it takes to reinstate the changes. + +An example merge request can be found in [CE merge request +23280](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/23280). + +## How it works + +The automatic merging is performed using a project called [Merge +Train](https://gitlab.com/gitlab-org/merge-train/). For every commit to merge or +revert, we generate patches using `git format-patch` which we then try to apply +using `git am --3way`. If this succeeds we push the changes to EE, if this fails +we decide what to do based on the failure reason: + +1. If the patch could not be applied because it was already applied, we just + skip it. +1. If the patch caused conflicts, we revert the source commits. + +Commits are reverted in reverse order, ensuring that if commit B depends on A, +and both conflict, we first revert B followed by reverting A. + +## FAQ + +### Why? + +We want to work towards being able to deploy continuously, but this requires +that `master` is always stable and has all the changes we need. If CE `master` +can not be merged into EE `master` due to merge conflicts, this prevents _any_ +change from CE making its way into EE. Since GitLab.com runs on EE, this +effectively prevents us from deploying changes. + +Past experiences and data have shown that periodic CE to EE merge requests do +not scale, and often take a very long time to complete. For example, [in this +comment](https://gitlab.com/gitlab-org/release/framework/issues/49#note_114614619) +we determined that the average time to close an upstream merge request is around +5 hours, with peaks up to several days. Periodic merge requests are also +frustrating to work with, because they often include many changes unrelated to +your own changes. + +Automatically merging or reverting commits allows us to keep merging changes +from CE into EE, as we never have to wait hours for somebody to resolve a set of +merge conflicts. + +### Does the CE to EE merge take into account merge commits? + +No. When merging CE changes into EE, merge commits are ignored. + +### My changes are reverted, but I set up an EE MR to resolve conflicts + +Most likely the automatic merge job ran before the EE merge request was merged. +If this keeps happening, consider reporting a bug in the [Merge Train issue +tracker](https://gitlab.com/gitlab-org/merge-train/issues). + +### My changes keep getting reverted, and this is really annoying! + +This is understandable, but the solution to this is fairly straightforward: +simply set up an EE merge request for every CE merge request, and resolve your +conflicts before the changes are reverted. + +### Will we allow certain people to still merge changes, even if they conflict? + +No. + +### Some files I work with often conflict, how can I best deal with this? + +If you find you keep running into merge conflicts, consider refactoring the file +so that the EE specific changes are not intertwined with CE code. For Ruby code +you can do this by moving the EE code to a separate module, which can then be +injected into the appropriate classes or modules. See [Guidelines for +implementing Enterprise Edition features](ee_features.md) for more information. + +### Will changelog entries be reverted automatically? + +Only if the changelog was added in the commit that was reverted. If a changelog +entry was added in a separate commit, it is possible for it to be left behind. +Since changelog entries are related to the changes in question, there is no real +reason to commit the changelog separately, and as such this should not be a big +problem. diff --git a/doc/development/code_review.md b/doc/development/code_review.md index 52710e54e86..7788d155154 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -5,7 +5,7 @@ having your code reviewed. All merge requests for GitLab CE and EE, whether written by a GitLab team member or a volunteer contributor, must go through a code review process to ensure the -code is effective, understandable, and maintainable. +code is effective, understandable, maintainable, and secure. ## Getting your merge request reviewed, approved, and merged @@ -20,12 +20,24 @@ importance of involving reviewer(s) in the section on the responsibility of the If you need some guidance (e.g. it's your first merge request), feel free to ask one of the [Merge request coaches][team]. +If you need assistance with security scans or comments, feel free to include the +Security Team (`@gitlab-com/gl-security`) in the review. + Depending on the areas your merge request touches, it must be **approved** by one or more [maintainers](https://about.gitlab.com/handbook/engineering/#maintainer): For approvals, we use the approval functionality found in the merge request widget. Reviewers can add their approval by [approving additionally](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html#adding-or-removing-an-approval). +Getting your merge request **merged** also requires a maintainer. If it requires +more than one approval, the last maintainer to review and approve it will also merge it. + +### Approval guidelines + +As described in the section on the responsibility of the maintainer below, you +are recommended to get your merge request approved and merged by maintainer(s) +from teams other than your own. + 1. If your merge request includes backend changes [^1], it must be **approved by a [backend maintainer](https://about.gitlab.com/handbook/engineering/projects/#gitlab-ce_maintainers_backend)**. 1. If your merge request includes frontend changes [^1], it must be @@ -39,12 +51,14 @@ widget. Reviewers can add their approval by [approving additionally](https://doc 1. If your merge request includes a new dependency or a filesystem change, it must be **approved by a [Distribution team member][team]**. See how to work with the [Distribution team](https://about.gitlab.com/handbook/engineering/dev-backend/distribution/) for more details. -Getting your merge request **merged** also requires a maintainer. If it requires -more than one approval, the last maintainer to review and approve it will also merge it. +#### Security requirements -As described in the section on the responsibility of the maintainer below, you -are recommended to get your merge request approved and merged by maintainer(s) -from other teams than your own. + 1. If your merge request is processing, storing, or transferring any kind of [RED or ORANGE data](https://docs.google.com/document/d/15eNKGA3zyZazsJMldqTBFbYMnVUSQSpU14lo22JMZQY/edit) (this is a confidential document), it must be + **approved by a [Security Engineer][team]**. + 1. If your merge request involves implementing, utilizing, or is otherwise related to any type of authentication, authorization, or session handling mechanism, it must be + **approved by a [Security Engineer][team]**. + 1. If your merge request has a goal which requires a cryptographic function such as: confidentiality, integrity, authentication, or non-repudiation, it must be + **approved by a [Security Engineer][team]**. ### The responsibility of the merge request author @@ -54,9 +68,10 @@ merge request author. Before assigning a merge request to a maintainer for approval and merge, they should be confident that it actually solves the problem it was meant to solve, that it does so in the most appropriate way, that it satisfies all requirements, -and that there are no remaining bugs, logical problems, or uncovered edge cases. -The merge request should also have a completed task list in its description and -a passing CI pipeline to avoid unnecessary back and forth. +and that there are no remaining bugs, logical problems, uncovered edge cases, +or known vulnerabilities. The merge request should also have a completed task +list in its description and a passing CI pipeline to avoid unnecessary back and +forth. To reach the required level of confidence in their solution, an author is expected to involve other people in the investigation and implementation processes as @@ -72,6 +87,23 @@ If an author is unsure if a merge request needs a domain expert's opinion, that' usually a pretty good sign that it does, since without it the required level of confidence in their solution will not have been reached. +Before the review, the author is requested to submit comments on the merge +request diff alerting the reviewer to anything important as well as for anything +that demands further explanation or attention. Examples of content that may +warrant a comment could be: + +- The addition of a linting rule (Rubocop, JS etc) +- The addition of a library (Ruby gem, JS lib etc) +- Where not obvious, a link to the parent class or method +- Any benchmarking performed to complement the change +- Potentially insecure code + +Do not add these comments directly to the source code, unless the +reviewer requires you to do so. + +This +[saves reviewers time and helps authors catch mistakes earlier](https://www.ibm.com/developerworks/rational/library/11-proven-practices-for-peer-review/index.html#__RefHeading__97_174136755). + ### The responsibility of the maintainer Maintainers are responsible for the overall health, quality, and consistency of @@ -85,8 +117,8 @@ Since a maintainer's job only depends on their knowledge of the overall GitLab codebase, and not that of any specific domain, they can review, approve and merge merge requests from any team and in any product area. -In fact, authors are recommended to get their merge requests merged by maintainers -from other teams than their own, to ensure that all code across GitLab is consistent +In fact, authors are encouraged to get their merge requests merged by maintainers +from teams other than their own, to ensure that all code across GitLab is consistent and can be easily understood by all contributors, from both inside and outside the company, without requiring team-specific expertise. @@ -103,6 +135,18 @@ as the maintainer to ultimately approve and merge it. Maintainers should check before merging if the merge request is approved by the required approvers. +Maintainers must check before merging if the merge request is introducing new +vulnerabilities, by inspecting the list in the Merge Request [Security +Widget](https://docs.gitlab.com/ee/user/project/merge_requests/#security-reports-ultimate). +When in doubt, a [Security Engineer][team] can be involved. The list of detected +vulnerabilities must be either empty or containing: + +- dismissed vulnerabilities in case of false positives +- vulnerabilities converted to issues + +Maintainers should **never** dismiss vulnerabilities to "empty" the list, +without duly verifying them. + ## Best practices ### Everyone @@ -153,7 +197,7 @@ first time. ### Assigning a merge request for a review -If you want to have your merge request reviewed you can assign it to any reviewer. The list of reviewers can be found on [Engineering projects](https://about.gitlab.com/handbook/engineering/projects/) page. +If you want to have your merge request reviewed, you can assign it to any reviewer. The list of reviewers can be found on [Engineering projects](https://about.gitlab.com/handbook/engineering/projects/) page. You can also use `ready for review` label. That means that your merge request is ready to be reviewed and any reviewer can pick it. It is recommended to use that label only if there isn't time pressure and make sure the merge request is assigned to a reviewer. @@ -189,6 +233,9 @@ experience, refactors the existing code). Then: subsequent revisions for anything that would be spotted after that. - Consider using the [Squash and merge][squash-and-merge] feature when the merge request has a lot of commits. + When merging code a maintainer should only use the squash feature if the + author has already set this option or if the merge request clearly contains a + messy commit history that is intended to be squashed. [squash-and-merge]: https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html#squash-and-merge diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md index b7990e1b558..55aed023325 100644 --- a/doc/development/documentation/index.md +++ b/doc/development/documentation/index.md @@ -368,6 +368,16 @@ You can combine one or more of the following: = link_to 'Help page', help_page_path('user/permissions') ``` +### GitLab `/help` tests + +Several [rspec tests](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/spec/features/help_pages_spec.rb) +are run to ensure GitLab documentation renders and works correctly. In particular, that [main docs landing page](../../README.md) will work correctly from `/help`. +For example, [GitLab.com's `/help`](https://gitlab.com/help). + +CAUTION: **Caution:** +Because the rspec tests only run in a full pipeline, and not a special [docs-only pipeline](#branch-naming), it is possible +to merge changes that will break `master` from a merge request with a successful docs-only pipeline run. + ## General Documentation vs Technical Articles ### General documentation @@ -552,6 +562,7 @@ Currently, the following tests are in place: As CE is merged into EE once a day, it's important to avoid merge conflicts. Submitting an EE-equivalent merge request cherry-picking all commits from CE to EE is essential to avoid them. +1. In a full pipeline, tests for [`/help`](#gitlab-help-tests). ### Linting diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index a227e2f6640..790b1bf951b 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -511,7 +511,7 @@ module EE params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: ::API::API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do # ... end end diff --git a/doc/development/fe_guide/graphql.md b/doc/development/fe_guide/graphql.md new file mode 100644 index 00000000000..f55f01720fd --- /dev/null +++ b/doc/development/fe_guide/graphql.md @@ -0,0 +1,83 @@ +# GraphQL + +We use [Apollo] and [Vue Apollo][vue-apollo] for working with GraphQL +on the frontend. + +In order to use GraphQL, you need to enable the `graphql` feature flag, +read more about [Feature Flags][feature-flags]. + +## Apollo Client + +To save duplicated clients getting created in different apps, we have a +[default client][defualt-client] that should be used. This setups the +Apollo client with the correct URL and also sets the CSRF headers. + +## GraphQL Queries + +To save query compilation at runtime, webpack can directly import `.graphql` +files. This allows webpack to preprocess the query at compile time instead +of the client doing compilation of queries. + +## Usage in Vue + +To use Vue Apollo, import the [Vue Apollo][vue-apollo] plugin as well +as the default client. This should be created at the same point +the Vue application is mounted. + +```javascript +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import defaultClient from '~/lib/graphql'; +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient, +}); + +new Vue({ + ..., + apolloProvider, + ... +}); +``` + +Read more about [Vue Apollo][vue-apollo] in the [Vue Apollo documentation][vue-apollo-docs]. + +### Testing + +With [Vue test utils][vue-test-utils] it is easy to quickly test components that +fetch GraphQL queries. The simplest way is to use `shallowMount` and then set +the data on the component + +```javascript +it('tests apollo component', () => { + const vm = shallowMount(App); + + vm.setData({ + ...mock data + }); +}); +``` + +## Usage outside of Vue + +It is also possible to use GraphQL outside of Vue by directly importing +and using the default client with queries. + +```javascript +import defaultClient from '~/lib/graphql'; +import query from './query.graphql'; + +defaultClient.query(query) + .then(result => console.log(result)); +``` + +Read more about the [Apollo] client in the [Apollo documentation][apollo-client-docs]. + +[Apollo]: https://www.apollographql.com/ +[vue-apollo]: https://github.com/Akryum/vue-apollo/ +[vue-apollo-docs]: https://akryum.github.io/vue-apollo/ +[feature-flags]: ../feature_flags.md +[default-client]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/lib/graphql.js +[apollo-client-docs]: https://www.apollographql.com/docs/tutorial/client.html +[vue-test-utils]: https://vue-test-utils.vuejs.org/ diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md index 11b9a2e6a64..cca3ad6fae6 100644 --- a/doc/development/fe_guide/index.md +++ b/doc/development/fe_guide/index.md @@ -54,6 +54,9 @@ Vuex specific design patterns and practices. ## [Axios](axios.md) Axios specific practices and gotchas. +## [GraphQL](graphql.md) +How to use GraphQL + ## [Icons and Illustrations](icons.md) How we use SVG for our Icons and Illustrations. diff --git a/doc/development/feature_flags.md b/doc/development/feature_flags.md index 1019a1fd0e2..b6161cd6163 100644 --- a/doc/development/feature_flags.md +++ b/doc/development/feature_flags.md @@ -113,7 +113,15 @@ feature flag. You can stub a feature flag as follows: stub_feature_flags(my_feature_flag: false) ``` -## Enabling a feature flag +## Enabling a feature flag (in development) + +In the rails console (`rails c`), enter the following command to enable your feature flag + +```ruby +Feature.enable(:feature_flag_name) +``` + +## Enabling a feature flag (in production) Check how to [roll out changes using feature flags](rolling_out_changes_using_feature_flags.md). diff --git a/doc/development/github_importer.md b/doc/development/github_importer.md index e860bde48dc..863ac049db6 100644 --- a/doc/development/github_importer.md +++ b/doc/development/github_importer.md @@ -131,7 +131,7 @@ our import as failed because of this. To prevent this from happening we periodically refresh the expiration time of the import process. This works by storing the JID of the import job in the database, then refreshing this JID's TTL at various stages throughout the import -process. This is done by calling `Project#refresh_import_jid_expiration`. By +process. This is done by calling `ProjectImportState#refresh_jid_expiration`. By refreshing this TTL we can ensure our import does not get marked as failed so long we're still performing work. diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md index 84dea7ce9aa..a5a34d82bec 100644 --- a/doc/development/gotchas.md +++ b/doc/development/gotchas.md @@ -92,7 +92,7 @@ describe API::Labels do end ``` -## Avoid using `allow_any_instance_of` in RSpec +## Avoid using `expect_any_instance_of` or `allow_any_instance_of` in RSpec ### Why @@ -102,7 +102,7 @@ end error like this: ``` - 1.1) Failure/Error: allow_any_instance_of(ApplicationSetting).to receive_messages(messages) + 1.1) Failure/Error: expect_any_instance_of(ApplicationSetting).to receive_messages(messages) Using `any_instance` to stub a method (elasticsearch_indexing) that has been defined on a prepended module (EE::ApplicationSetting) is not supported. ``` @@ -112,7 +112,7 @@ Instead of writing: ```ruby # Don't do this: -allow_any_instance_of(Project).to receive(:add_import_job) +expect_any_instance_of(Project).to receive(:add_import_job) ``` We could write: diff --git a/doc/development/i18n/proofreader.md b/doc/development/i18n/proofreader.md index f75f5882b56..8f1317e235d 100644 --- a/doc/development/i18n/proofreader.md +++ b/doc/development/i18n/proofreader.md @@ -43,7 +43,7 @@ are very appreciative of the work done by translators and proofreaders! - Greek - Proofreaders needed. - Hebrew - - Proofreaders needed. + - Yaron Shahrabani - [GitLab](https://gitlab.com/yarons), [Crowdin](https://crowdin.com/profile/YaronSh) - Hungarian - Proofreaders needed. - Indonesian @@ -57,12 +57,14 @@ are very appreciative of the work done by translators and proofreaders! - Chang-Ho Cha - [GitLab](https://gitlab.com/changho-cha), [Crowdin](https://crowdin.com/profile/zzazang) - Huang Tao - [GitLab](https://gitlab.com/htve), [Crowdin](https://crowdin.com/profile/htve) - Ji Hun Oh - [GitLab](https://gitlab.com/Baw-Appie), [Crowdin](https://crowdin.com/profile/BawAppie) + - Jeongwhan Choi - [GitLab](https://gitlab.com/jeongwhanchoi), [Crowdin](https://crowdin.com/profile/jeongwhanchoi) - Mongolian - Proofreaders needed. - Norwegian Bokmal - Proofreaders needed. - Polish - Filip Mech - [GitLab](https://gitlab.com/mehenz), [Crowdin](https://crowdin.com/profile/mehenz) + - Maksymilian Roman - [GitLab](https://gitlab.com/villaincandle), [Crowdin](https://crowdin.com/profile/villaincandle) - Portuguese - Proofreaders needed. - Portuguese, Brazilian diff --git a/doc/development/new_fe_guide/development/testing.md b/doc/development/new_fe_guide/development/testing.md index 082acbedcd2..0d98180add0 100644 --- a/doc/development/new_fe_guide/development/testing.md +++ b/doc/development/new_fe_guide/development/testing.md @@ -6,9 +6,15 @@ Tests relevant for frontend development can be found at two places: - [frontend unit tests](#frontend-unit-tests) - [frontend component tests](#frontend-component-tests) - [frontend integration tests](#frontend-integration-tests) +- `spec/frontend/` which are run by Jest and contain + - [frontend unit tests](#frontend-unit-tests) + - [frontend component tests](#frontend-component-tests) + - [frontend integration tests](#frontend-integration-tests) - `spec/features/` which are run by RSpec and contain - [feature tests](#feature-tests) +All tests in `spec/javascripts/` will eventually be migrated to `spec/frontend/` (see also [#53757]). + In addition there were feature tests in `features/` run by Spinach in the past. These have been removed from our codebase in May 2018 ([#23036](https://gitlab.com/gitlab-org/gitlab-ce/issues/23036)). @@ -17,6 +23,8 @@ See also: - [old testing guide](../../testing_guide/frontend_testing.html) - [notes on testing Vue components](../../fe_guide/vue.html#testing-vue-components) +[#53757]: https://gitlab.com/gitlab-org/gitlab-ce/issues/53757 + ## Frontend unit tests Unit tests are on the lowest abstraction level and typically test functionality that is not directly perceivable by a user. diff --git a/doc/development/profiling.md b/doc/development/profiling.md index 0ca8bb67a77..0b0c6dfc8cf 100644 --- a/doc/development/profiling.md +++ b/doc/development/profiling.md @@ -77,8 +77,11 @@ that builds on this to add some additional niceties, such as allowing configuration with a single Yaml file for multiple URLs, and uploading of the profile and log output to S3. -For GitLab.com, you can find the latest results here: -<http://redash.gitlab.com/dashboard/gitlab-profiler-statistics> +For GitLab.com, currently the latest profiling data has been [moved from +Redash to Looker](https://gitlab.com/gitlab-com/Product/issues/5#note_121194467). +We are [currently investigating how to make this data +public](https://gitlab.com/meltano/looker/issues/294). + ## Sherlock diff --git a/doc/development/testing_guide/ci.md b/doc/development/testing_guide/ci.md index 8d9706a9501..d685cacf9ea 100644 --- a/doc/development/testing_guide/ci.md +++ b/doc/development/testing_guide/ci.md @@ -31,11 +31,7 @@ After that, the next pipeline will use the up-to-date The GitLab test suite is [monitored] for the `master` branch, and any branch that includes `rspec-profile` in their name. -A [public dashboard] is available for everyone to see. Feel free to look at the -slowest test files and try to improve them. - [monitored]: ../performance.md#rspec-profiling -[public dashboard]: https://redash.gitlab.com/public/dashboards/l1WhHXaxrCWM5Ai9D7YDqHKehq6OU3bx5gssaiWe?org_slug=default ## CI setup diff --git a/doc/development/testing_guide/review_apps.md b/doc/development/testing_guide/review_apps.md index 1830641431e..309babb5f94 100644 --- a/doc/development/testing_guide/review_apps.md +++ b/doc/development/testing_guide/review_apps.md @@ -7,31 +7,36 @@ Review Apps are automatically deployed by each pipeline, both in ## How does it work? 1. On every [pipeline][gitlab-pipeline] during the `test` stage, the - [`review` job][review-job] is automatically started. -1. The `review` job [triggers a pipeline][cng-pipeline] in the - [`CNG-mirror`][cng-mirror] project. - - We use the `CNG-mirror` project so that the `CNG`, (**C**loud **N**ative - **G**itLab), project's registry is not overloaded with a lot of transient - Docker images. -1. The `CNG-mirror` pipeline creates the Docker images of each component (e.g. - `gitlab-rails-ee`, `gitlab-shell`, `gitaly` etc.) based on the commit from the - [GitLab pipeline][gitlab-pipeline] and store them in its - [registry][cng-mirror-registry]. -1. Once all images are built, the Review App is deployed using - [the official GitLab Helm chart][helm-chart] to the - [`review-apps-ee` Kubernetes cluster on GCP][review-apps-ee] - - The actual scripts used to deploy the Review App can be found at - [`scripts/review_apps/review-apps.sh`][review-apps.sh] - - These scripts are basically - [our official Auto DevOps scripts][Auto-DevOps.gitlab-ci.yml] where the - default CNG images are overridden with the images built and stored in the - [`CNG-mirror` project's registry][cng-mirror-registry]. - - Since we're using [the official GitLab Helm chart][helm-chart], this means - you get a dedicated environment for your branch that's very close to what it - would look in production. -1. Once the `review` job succeeds, you should be able to use your Review App - thanks to the direct link to it from the MR widget. The default username is - `root` and its password can be found in the 1Password secure note named + [`review-deploy`][review-deploy-job] job is automatically started. +1. The `review-deploy` job: + 1. Waits for the `gitlab:assets:compile` job to finish since the + [`CNG-mirror`][cng-mirror] pipeline triggerred in the following step + depends on it. + 1. [Triggers a pipeline][cng-pipeline] in the [`CNG-mirror`][cng-mirror] + project. + - We use the `CNG-mirror` project so that the `CNG`, (**C**loud + **N**ative **G**itLab), project's registry is not overloaded with a + lot of transient Docker images. + - The `CNG-mirror` pipeline creates the Docker images of each component + (e.g. `gitlab-rails-ee`, `gitlab-shell`, `gitaly` etc.) based on the + commit from the [GitLab pipeline][gitlab-pipeline] and store them in + its [registry][cng-mirror-registry]. + 1. Once all images are built by [`CNG-mirror`][cng-mirror], the Review App + is deployed using [the official GitLab Helm chart][helm-chart] to the + [`review-apps-ce`][review-apps-ce] / [`review-apps-ee`][review-apps-ee] + Kubernetes cluster on GCP. + - The actual scripts used to deploy the Review App can be found at + [`scripts/review_apps/review-apps.sh`][review-apps.sh]. + - These scripts are basically + [our official Auto DevOps scripts][Auto-DevOps.gitlab-ci.yml] where the + default CNG images are overridden with the images built and stored in the + [`CNG-mirror` project's registry][cng-mirror-registry]. + - Since we're using [the official GitLab Helm chart][helm-chart], this means + you get a dedicated environment for your branch that's very close to what + it would look in production. +1. Once the `review-deploy` job succeeds, you should be able to use your Review + App thanks to the direct link to it from the MR widget. The default username + is `root` and its password can be found in the 1Password secure note named **gitlab-{ce,ee} Review App's root password** (note that there's currently [a bug where the default password seems to be overridden][password-bug]). @@ -39,16 +44,58 @@ Review Apps are automatically deployed by each pipeline, both in - The Kubernetes cluster is connected to the `gitlab-{ce,ee}` projects using [GitLab's Kubernetes integration][gitlab-k8s-integration]. This basically - allows to have a link to the Review App directly from the merge request widget. -- The manual `stop_review` in the `test` stage can be used to stop a Review App - manually, and is also started by GitLab once a branch is deleted. -- Review Apps are cleaned up regularly using a pipeline schedule that runs - the [`scripts/review_apps/automated_cleanup.rb`][automated_cleanup.rb] script. + allows to have a link to the Review App directly from the merge request + widget. - If the Review App deployment fails, you can simply retry it (there's no need - to run the `stop_review` job first). -- If you're unable to log in using the `root` username and password, you may - encounter [this bug][password-bug]. Stop the Review App via the `stop_review` - manual job and then retry the `review` job to redeploy the Review App. + to run the [`review-stop`][gitlab-ci-yml] job first). +- The manual [`review-stop`][gitlab-ci-yml] in the `test` stage can be used to + stop a Review App manually, and is also started by GitLab once a branch is + deleted. +- Review Apps are cleaned up regularly using a pipeline schedule that runs + the [`schedule:review-cleanup`][gitlab-ci-yml] job. + +## QA runs + +On every [pipeline][gitlab-pipeline] during the `test` stage, the +`review-qa-smoke` job is automatically started: it runs the smoke QA suite. +You can also manually start the `review-qa-all`: it runs the full QA suite. + +Note that both jobs first wait for the `review-deploy` job to be finished. + +## How to? + +### Find my Review App slug? + +1. Open the `review-deploy` job. +1. Look for `Checking for previous deployment of review-*`. +1. For instance for `Checking for previous deployment of review-qa-raise-e-12chm0`, + your Review App slug would be `review-qa-raise-e-12chm0` in this case. + +### Run a Rails console? + +1. [Filter Workloads by your Review App slug](https://console.cloud.google.com/kubernetes/workload?project=gitlab-review-apps) + , e.g. `review-29951-issu-id2qax`. +1. Find and open the `task-runner` Deployment, e.g. `review-29951-issu-id2qax-task-runner`. +1. Click on the Pod in the "Managed pods" section, e.g. `review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz`. +1. Click on the `KUBECTL` dropdown, then `Exec` -> `task-runner`. +1. Replace `-c task-runner -- ls` with `-- /srv/gitlab/bin/rails c` from the + default command or + - Run `kubectl exec --namespace review-apps-ce -it review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz -- /srv/gitlab/bin/rails c` + and + - Replace `review-apps-ce` with `review-apps-ee` if the Review App + is running EE, and + - Replace `review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz` + with your Pod's name. + +### Dig into a Pod's logs? + +1. [Filter Workloads by your Review App slug](https://console.cloud.google.com/kubernetes/workload?project=gitlab-review-apps) + , e.g. `review-1979-1-mul-dnvlhv`. +1. Find and open the `migrations` Deployment, e.g. + `review-1979-1-mul-dnvlhv-migrations.1`. +1. Click on the Pod in the "Managed pods" section, e.g. + `review-1979-1-mul-dnvlhv-migrations.1-nqwtx`. +1. Click on the `Container logs` link. ## Frequently Asked Questions @@ -74,15 +121,17 @@ find a way to limit it to only us.** > This isn't enabled for forks. [gitlab-pipeline]: https://gitlab.com/gitlab-org/gitlab-ce/pipelines/35850709 -[review-job]: https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/118076368 +[review-deploy-job]: https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/118076368 [cng-mirror]: https://gitlab.com/gitlab-org/build/CNG-mirror [cng-pipeline]: https://gitlab.com/gitlab-org/build/CNG-mirror/pipelines/35883435 [cng-mirror-registry]: https://gitlab.com/gitlab-org/build/CNG-mirror/container_registry [helm-chart]: https://gitlab.com/charts/gitlab/ +[review-apps-ce]: https://console.cloud.google.com/kubernetes/clusters/details/us-central1-a/review-apps-ce?project=gitlab-review-apps [review-apps-ee]: https://console.cloud.google.com/kubernetes/clusters/details/us-central1-b/review-apps-ee?project=gitlab-review-apps [review-apps.sh]: https://gitlab.com/gitlab-org/gitlab-ee/blob/master/scripts/review_apps/review-apps.sh [automated_cleanup.rb]: https://gitlab.com/gitlab-org/gitlab-ee/blob/master/scripts/review_apps/automated_cleanup.rb [Auto-DevOps.gitlab-ci.yml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml +[gitlab-ci-yml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml [gitlab-k8s-integration]: https://docs.gitlab.com/ee/user/project/clusters/index.html [password-bug]: https://gitlab.com/gitlab-org/gitlab-ce/issues/53621 diff --git a/doc/install/docker.md b/doc/install/docker.md index c7dc9db70c5..e90f6645b0c 100644 --- a/doc/install/docker.md +++ b/doc/install/docker.md @@ -7,9 +7,10 @@ GitLab provides official Docker images to allowing you to easily take advantage ## Omnibus GitLab based images GitLab maintains a set of [official Docker images](https://hub.docker.com/r/gitlab) based on our [Omnibus GitLab package](https://docs.gitlab.com/omnibus/README.html). These images include: -* [GitLab Community Edition](https://hub.docker.com/r/gitlab/gitlab-ce/) -* [GitLab Enterprise Edition](https://hub.docker.com/r/gitlab/gitlab-ee/) -* [GitLab Runner](https://hub.docker.com/r/gitlab/gitlab-runner/) + +- [GitLab Community Edition](https://hub.docker.com/r/gitlab/gitlab-ce/). +- [GitLab Enterprise Edition](https://hub.docker.com/r/gitlab/gitlab-ee/). +- [GitLab Runner](https://hub.docker.com/r/gitlab/gitlab-runner/). A [complete usage guide](https://docs.gitlab.com/omnibus/docker/) to these images is available, as well as the [Dockerfile used for building the images](https://gitlab.com/gitlab-org/omnibus-gitlab/tree/master/docker). diff --git a/doc/install/installation.md b/doc/install/installation.md index 06293b8caf5..d041bfa863a 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -12,7 +12,7 @@ Since installations from source don't have Runit, Sidekiq can't be terminated an ## Select Version to Install -Make sure you view [this installation guide](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/installation.md) from the branch (version) of GitLab you would like to install (e.g., `11-5-stable`). +Make sure you view [this installation guide](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/installation.md) from the branch (version) of GitLab you would like to install (e.g., `11-6-stable`). You can select the branch in the version dropdown in the top left corner of GitLab (below the menu bar). If the highest number stable branch is unclear please check the [GitLab Blog](https://about.gitlab.com/blog/) for installation guide links by version. @@ -300,9 +300,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 11-5-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 11-6-stable gitlab -**Note:** You can change `11-5-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `11-6-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index 18c9cd116f1..a63656fafef 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -604,6 +604,8 @@ If you fail to restore this encryption key file along with the application data backup, users with two-factor authentication enabled and GitLab Runners will lose access to your GitLab server. +You may also want to restore any TLS keys, certificates, or [SSH host keys](https://superuser.com/questions/532040/copy-ssh-keys-from-one-server-to-another-server/532079#532079). + Depending on your case, you might want to run the restore command with one or more of the following options: diff --git a/doc/raketasks/web_hooks.md b/doc/raketasks/web_hooks.md index 5f3143f76cd..df3dab118b2 100644 --- a/doc/raketasks/web_hooks.md +++ b/doc/raketasks/web_hooks.md @@ -38,8 +38,6 @@ ## List the webhooks from projects in a given **NAMESPACE**: # omnibus-gitlab - sudo gitlab-rake gitlab:web_hook:list NAMESPACE=/ + sudo gitlab-rake gitlab:web_hook:list NAMESPACE=acme # source installations - bundle exec rake gitlab:web_hook:list NAMESPACE=/ RAILS_ENV=production - -> Note: `/` is the global namespace. + bundle exec rake gitlab:web_hook:list NAMESPACE=acme RAILS_ENV=production diff --git a/doc/security/two_factor_authentication.md b/doc/security/two_factor_authentication.md index b770f2544d2..4b65b901487 100644 --- a/doc/security/two_factor_authentication.md +++ b/doc/security/two_factor_authentication.md @@ -6,7 +6,7 @@ password to login, they'll be prompted for a code generated by an application on their phone. You can read more about it here: -[Two-factor Authentication (2FA)](../profile/two_factor_authentication.md) +[Two-factor Authentication (2FA)](../user/profile/account/two_factor_authentication.md) ## Enforcing 2FA for all users diff --git a/doc/topics/authentication/index.md b/doc/topics/authentication/index.md index 394f3ea60b7..766a23c419d 100644 --- a/doc/topics/authentication/index.md +++ b/doc/topics/authentication/index.md @@ -44,6 +44,5 @@ This page gathers all the resources for the topic **Authentication** within GitL - [Kanboard Plugin GitLab Authentication](https://github.com/kanboard/plugin-gitlab-auth) - [Jenkins GitLab OAuth Plugin](https://wiki.jenkins.io/display/JENKINS/GitLab+OAuth+Plugin) -- [Set up Gitlab CE with Active Directory authentication](https://www.caseylabs.com/setup-gitlab-ce-with-active-directory-authentication/) - [How to customize GitLab to support OpenID authentication](http://eric.van-der-vlist.com/blog/2013/11/23/how-to-customize-gitlab-to-support-openid-authentication/) - [Openshift - Configuring Authentication and User Agent](https://docs.openshift.org/latest/install_config/configuring_authentication.html#GitLab) diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 6bb2e236dc1..7885cffd107 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -132,7 +132,8 @@ in three places: - either under the project's CI/CD settings while [enabling Auto DevOps](#enabling-auto-devops) - or in instance-wide settings in the **admin area > Settings** under the "Continuous Integration and Delivery" section -- or at the project or group level as a variable: `AUTO_DEVOPS_DOMAIN` (required if you want to use [multiple clusters](#using-multiple-kubernetes-clusters)) +- or at the project as a variable: `AUTO_DEVOPS_DOMAIN` (required if you want to use [multiple clusters](#using-multiple-kubernetes-clusters)) +- or at the group level as a variable: `AUTO_DEVOPS_DOMAIN` A wildcard DNS A record matching the base domain(s) is required, for example, given a base domain of `example.com`, you'd need a DNS entry like: @@ -203,6 +204,12 @@ and verifying that your app is deployed as a review app in the Kubernetes cluster with the `review/*` environment scope. Similarly, you can check the other environments. +NOTE: **Note:** +Auto DevOps is not supported for a group with multiple clusters, as it +is not possible to set `AUTO_DEVOPS_DOMAIN` per environment on the group +level. This will be resolved in the future with the [following issue]( +https://gitlab.com/gitlab-org/gitlab-ce/issues/52363). + ## Enabling/Disabling Auto DevOps When first using Auto Devops, review the [requirements](#requirements) to ensure all necessary components to make @@ -479,14 +486,23 @@ no longer be valid as soon as the deployment job finishes. This means that Kubernetes can run the application, but in case it should be restarted or executed somewhere else, it cannot be accessed again. +#### Migrations + > [Introduced][ce-21955] in GitLab 11.4 Database initialization and migrations for PostgreSQL can be configured to run within the application pod by setting the project variables `DB_INITIALIZE` and `DB_MIGRATE` respectively. -If present, `DB_INITIALIZE` will be run as a shell command within an application pod as a helm -post-install hook. Note that this means that if any deploy succeeds, +If present, `DB_INITIALIZE` will be run as a shell command within an +application pod as a helm post-install hook. As some applications will +not run without a successful database initialization step, GitLab will +deploy the first release without the application deployment and only the +database initialization step. After the database initialization completes, +GitLab will deploy a second release with the application deployment as +normal. + +Note that a post-install hook means that if any deploy succeeds, `DB_INITIALIZE` will not be processed thereafter. If present, `DB_MIGRATE` will be run as a shell command within an application pod as @@ -657,8 +673,6 @@ also be customized, and you can easily use a [custom buildpack](#custom-buildpac | `REVIEW_DISABLED` | From GitLab 11.0, this variable can be used to disable the `review` and the manual `review:stop` job. If the variable is present, these jobs will not be created. | | `DAST_DISABLED` | From GitLab 11.0, this variable can be used to disable the `dast` job. If the variable is present, the job will not be created. | | `PERFORMANCE_DISABLED` | From GitLab 11.0, this variable can be used to disable the `performance` job. If the variable is present, the job will not be created. | -| `OLD_REPORTS_DISABLED` | From GitLab 11.5, this variable can be used to disable the `sast` job. If the variable is present, the job will not be created. | -| `NEW_REPORTS_DISABLED` | From GitLab 11.5, this variable can be used to disable the `sast_dashboard` job. If the variable is present, the job will not be created. | TIP: **Tip:** Set up the replica variables using a diff --git a/doc/update/11.5-to-11.6.md b/doc/update/11.5-to-11.6.md new file mode 100644 index 00000000000..031abc434ca --- /dev/null +++ b/doc/update/11.5-to-11.6.md @@ -0,0 +1,390 @@ +--- +comments: false +--- + +# From 11.5 to 11.6 + +Make sure you view this update guide from the branch (version) of GitLab you would +like to install (e.g., `11-6-stable`. You can select the branch in the version +dropdown at the top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + +### 1. Stop server + +```bash +sudo service gitlab stop +``` + +### 2. Backup + +NOTE: If you installed GitLab from source, make sure `rsync` is installed. + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Update Ruby + +NOTE: GitLab 11.0 and higher only support Ruby 2.4.x and dropped support for Ruby 2.3.x. Be +sure to upgrade your interpreter if necessary. + +You can check which version you are running with `ruby -v`. + +Download Ruby and compile it: + +```bash +mkdir /tmp/ruby && cd /tmp/ruby +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.5/ruby-2.5.3.tar.gz +echo 'f919a9fbcdb7abecd887157b49833663c5c15fda ruby-2.5.3.tar.gz' | shasum -c - && tar xzf ruby-2.5.3.tar.gz +cd ruby-2.5.3 + +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Update Node + +GitLab utilizes [webpack](http://webpack.js.org) to compile frontend assets. +This requires a minimum version of node v6.0.0. + +You can check which version you are running with `node -v`. If you are running +a version older than `v6.0.0` you will need to update to a newer version. You +can find instructions to install from community maintained packages or compile +from source at the nodejs.org website. + +<https://nodejs.org/en/download/> + +GitLab also requires the use of yarn `>= v1.2.0` to manage JavaScript +dependencies. + +```bash +curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - +echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list +sudo apt-get update +sudo apt-get install yarn +``` + +More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install). + +### 5. Update Go + +NOTE: GitLab 11.4 and higher only supports Go 1.10.x and newer, and dropped support for Go +1.9.x. Be sure to upgrade your installation if necessary. + +You can check which version you are running with `go version`. + +Download and install Go: + +```bash +# Remove former Go installation folder +sudo rm -rf /usr/local/go + +curl --remote-name --progress https://dl.google.com/go/go1.10.5.linux-amd64.tar.gz +echo 'a035d9beda8341b645d3f45a1b620cf2d8fb0c5eb409be36b389c0fd384ecc3a go1.10.5.linux-amd64.tar.gz' | shasum -a256 -c - && \ + sudo tar -C /usr/local -xzf go1.10.5.linux-amd64.tar.gz +sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ +rm go1.10.5.linux-amd64.tar.gz +``` + +### 6. Get latest code + +```bash +cd /home/git/gitlab + +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 +``` + +For GitLab Community Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 11-6-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 11-6-stable-ee +``` + +### 7. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell + +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 +``` + +### 8. Update gitlab-workhorse + +Install and compile gitlab-workhorse. GitLab-Workhorse uses +[GNU Make](https://www.gnu.org/software/make/). +If you are not using Linux you may have to run `gmake` instead of +`make` below. + +```bash +cd /home/git/gitlab-workhorse + +sudo -u git -H git fetch --all --tags --prune +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION) +sudo -u git -H make +``` + +### 9. Update Gitaly + +#### Check Gitaly configuration + +Due to a bug in the `rake gitlab:gitaly:install` script your Gitaly +configuration file may contain syntax errors. The block name +`[[storages]]`, which may occur more than once in your `config.toml` +file, should be `[[storage]]` instead. + +```shell +sudo -u git -H sed -i.pre-10.1 's/\[\[storages\]\]/[[storage]]/' /home/git/gitaly/config.toml +``` + +#### Compile Gitaly + +```shell +cd /home/git/gitaly +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 +``` + +### 10. Update gitlab-pages + +#### Only needed if you use GitLab Pages + +Install and compile gitlab-pages. GitLab-Pages uses +[GNU Make](https://www.gnu.org/software/make/). +If you are not using Linux you may have to run `gmake` instead of +`make` below. + +```bash +cd /home/git/gitlab-pages + +sudo -u git -H git fetch --all --tags --prune +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_PAGES_VERSION) +sudo -u git -H make +``` + +### 11. Update MySQL permissions + +If you are using MySQL you need to grant the GitLab user the necessary +permissions on the database: + +```bash +mysql -u root -p -e "GRANT TRIGGER ON \`gitlabhq_production\`.* TO 'git'@'localhost';" +``` + +If you use MySQL with replication, or just have MySQL configured with binary logging, +you will need to also run the following on all of your MySQL servers: + +```bash +mysql -u root -p -e "SET GLOBAL log_bin_trust_function_creators = 1;" +``` + +You can make this setting permanent by adding it to your `my.cnf`: + +``` +log_bin_trust_function_creators=1 +``` + +### 12. Update configuration files + +#### New `unicorn.rb` configuration + +We have made [changes](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22372) to `unicorn.rb` to allow GitLab run with both Unicorn and Puma in future. + +Make `/home/git/gitlab/config/unicorn.rb` the same as https://gitlab.com/gitlab-org/gitlab-ce/blob/11-6-stable/config/unicorn.rb.example but with your settings. +In particular, make sure that `require_relative "/home/git/gitlab/lib/gitlab/cluster/lifecycle_events"` line exists and the `before_exec`, `before_fork`, and `after_fork` handlers are configured as shown below: + +```ruby +require_relative "/home/git/gitlab/lib/gitlab/cluster/lifecycle_events" + +before_exec do |server| + # Signal application hooks that we're about to restart + Gitlab::Cluster::LifecycleEvents.do_master_restart +end + +before_fork do |server, worker| + # Signal application hooks that we're about to fork + Gitlab::Cluster::LifecycleEvents.do_before_fork +end + +after_fork do |server, worker| + # Signal application hooks of worker start + Gitlab::Cluster::LifecycleEvents.do_worker_start +end +``` + +#### New configuration options for `gitlab.yml` + +There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +cd /home/git/gitlab + +git diff origin/11-5-stable:config/gitlab.yml.example origin/11-6-stable:config/gitlab.yml.example +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +cd /home/git/gitlab + +# For HTTPS configurations +git diff origin/11-5-stable:lib/support/nginx/gitlab-ssl origin/11-6-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/11-5-stable:lib/support/nginx/gitlab origin/11-6-stable:lib/support/nginx/gitlab +``` + +If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx +configuration as GitLab application no longer handles setting it. + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/11-6-stable/lib/support/init.d/gitlab.default.example#L38 + +#### SMTP configuration + +If you're installing from source and use SMTP to deliver mail, you will need to add the following line +to config/initializers/smtp_settings.rb: + +```ruby +ActionMailer::Base.delivery_method = :smtp +``` + +See [smtp_settings.rb.sample] as an example. + +[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/11-6-stable/config/initializers/smtp_settings.rb.sample#L13 + +#### Init script + +There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`: + +```sh +cd /home/git/gitlab + +git diff origin/11-5-stable:lib/support/init.d/gitlab.default.example origin/11-6-stable:lib/support/init.d/gitlab.default.example +``` + +Ensure you're still up-to-date with the latest init script changes: + +```bash +cd /home/git/gitlab + +sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab +``` + +For Ubuntu 16.04.1 LTS: + +```bash +sudo systemctl daemon-reload +``` + +### 13. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Compile GetText PO files + +sudo -u git -H bundle exec rake gettext:compile RAILS_ENV=production + +# Update node dependencies and recompile assets +sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production + +# Clean up cache +sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production +``` + +**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md). + +### 14. Start application + +```bash +sudo service gitlab start +sudo service nginx restart +``` + +### 15. Check application status + +Check if GitLab and its environment are configured correctly: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production +``` + +To make sure you didn't miss anything run a more thorough check: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production +``` + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (11.5) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 11.4 to 11.5](11.4-to-11.5.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. + +[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/11-6-stable/config/gitlab.yml.example +[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/11-6-stable/lib/support/init.d/gitlab.default.example diff --git a/doc/user/admin_area/custom_project_templates.md b/doc/user/admin_area/custom_project_templates.md new file mode 100644 index 00000000000..5afbf9f2934 --- /dev/null +++ b/doc/user/admin_area/custom_project_templates.md @@ -0,0 +1,25 @@ +# Custom instance-level project templates **[PREMIUM ONLY]** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/6860) in [GitLab Premium](https://about.gitlab.com/pricing) 11.2. + +When you create a new project, creating it based on custom project templates is +a convenient option to bootstrap from an existing project boilerplate. +The administration setting to configure a GitLab group that serves as template +source can be found under **Admin > Settings > Custom project templates**. + +Within this section, you can configure the group where all the custom project +templates are sourced. Every project directly under the group namespace will be +available to the user if they have access to them. For example, every public +project in the group will be available to every logged user. However, +private projects will be available only if the user has view [permissions](../permissions.md) +in the project: + +- Project Owner, Maintainer, Developer, Reporter or Guest +- Is a member of the Group: Owner, Maintainer, Developer, Reporter or Guest + +Projects below subgroups of the template group are **not** supported. + +Repository and database information that are copied over to each new project are +identical to the data exported with [GitLab's Project Import/Export](../project/settings/import_export.md). + +If you would like to set project templates at a group level, please see [Custom group-level project templates](../group/custom_project_templates.md).
\ No newline at end of file diff --git a/doc/user/group/clusters/index.md b/doc/user/group/clusters/index.md new file mode 100644 index 00000000000..adc43921d47 --- /dev/null +++ b/doc/user/group/clusters/index.md @@ -0,0 +1,126 @@ +# Group-level Kubernetes clusters + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/34758) in GitLab 11.6. + +CAUTION: **Warning:** +Group Cluster integration is currently in **Beta**. + +## Overview + +Similar to [project Kubernetes +clusters](../../project/clusters/index.md), Group-level Kubernetes +clusters allow you to connect a Kubernetes cluster to your group, +enabling you to use the same cluster across multiple projects. + +## Installing applications + +GitLab provides a one-click install for various applications that can be +added directly to your cluster. + +NOTE: **Note:** +Applications will be installed in a dedicated namespace called +`gitlab-managed-apps`. If you have added an existing Kubernetes cluster +with Tiller already installed, you should be careful as GitLab cannot +detect it. In this event, installing Tiller via the applications will +result in the cluster having it twice. This can lead to confusion during +deployments. + +| Application | GitLab version | Description | Helm Chart | +| ----------- | -------------- | ----------- | ---------- | +| [Helm Tiller](https://docs.helm.sh) | 10.2+ | Helm is a package manager for Kubernetes and is required to install all the other applications. It is installed in its own pod inside the cluster which can run the `helm` CLI in a safe environment. | n/a | +| [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress) | 10.2+ | Ingress can provide load balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps](../../../topics/autodevops/index.md) or deploy your own web apps. | [stable/nginx-ingress](https://github.com/helm/charts/tree/master/stable/nginx-ingress) | + +## RBAC compatibility + +For each project under a group with a Kubernetes cluster, GitLab will +create a restricted service account with [`edit` +privileges](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) +in the project namespace. + +NOTE: **Note:** +RBAC support was introduced in +[GitLab 11.4](https://gitlab.com/gitlab-org/gitlab-ce/issues/29398), and +Project namespace restriction was introduced in +[GitLab 11.5](https://gitlab.com/gitlab-org/gitlab-ce/issues/51716). + +## Cluster precedence + +GitLab will use the project's cluster before using any cluster belonging +to the group containing the project if the project's cluster is available and not disabled. + +In the case of sub-groups, GitLab will use the cluster of the closest ancestor group +to the project, provided the cluster is not disabled. + +## Multiple Kubernetes clusters **[PREMIUM]** + +With GitLab Premium, you can associate more than one Kubernetes clusters to your +group. That way you can have different clusters for different environments, +like dev, staging, production, etc. + +Add another cluster similar to the first one and make sure to +[set an environment scope](#environment-scopes) that will +differentiate the new cluster from the rest. + +NOTE: **Note:** +Auto DevOps is not supported for a group with multiple clusters, as it +is not possible to set `AUTO_DEVOPS_DOMAIN` per environment on the group +level. This will be resolved in the future with the [following issue]( +https://gitlab.com/gitlab-org/gitlab-ce/issues/52363). + +## Environment scopes **[PREMIUM]** + +When adding more than one Kubernetes cluster to your project, you need +to differentiate them with an environment scope. The environment scope +associates clusters with [environments](../../../ci/environments.md) +similar to how the [environment-specific +variables](../../../ci/variables/README.md#limiting-environment-scopes-of-variables) +work. + +While evaluating which environment matches the environment scope of a +cluster, [cluster precedence](#cluster-precedence) will take +effect. The cluster at the project level will take precedence, followed +by the closest ancestor group, followed by that groups' parent and so +on. + +For example, let's say we have the following Kubernetes clusters: + +| Cluster | Environment scope | Where | +| ---------- | ------------------- | ----------| +| Project | `*` | Project | +| Staging | `staging/*` | Project | +| Production | `production/*` | Project | +| Test | `test` | Group | +| Development| `*` | Group | + + +And the following environments are set in [`.gitlab-ci.yml`](../../../ci/yaml/README.md): + +```yaml +stages: +- test +- deploy + +test: + stage: test + script: sh test + +deploy to staging: + stage: deploy + script: make deploy + environment: + name: staging/$CI_COMMIT_REF_NAME + url: https://staging.example.com/ + +deploy to production: + stage: deploy + script: make deploy + environment: + name: production/$CI_COMMIT_REF_NAME + url: https://example.com/ +``` + +The result will then be: + +- The Project cluster will be used for the `test` job. +- The Staging cluster will be used for the `deploy to staging` job. +- The Production cluster will be used for the `deploy to production` job. diff --git a/doc/user/group/custom_project_templates.md b/doc/user/group/custom_project_templates.md new file mode 100644 index 00000000000..eaf0273050b --- /dev/null +++ b/doc/user/group/custom_project_templates.md @@ -0,0 +1,23 @@ +# Custom group-level project templates **[PREMIUM ONLY]** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/6861) in [GitLab Premium](https://about.gitlab.com/pricing) 11.6. + +When you create a new project, creating it based on custom project templates is +a convenient option to bootstrap from an existing project boilerplate. +The group-level setting to configure a GitLab group that serves as template +source can be found under **Group > Settings > General > Custom project templates**. + +Within this section, you can configure the group where all the custom project +templates are sourced. Every project directly under the group namespace will be +available to the user if they have access to them. For example, every public +project in the group will be available to every logged in user. However, +private projects will be available only if the user has view [permissions](../permissions.md) +in the project. That is, users with Owner, Maintainer, Developer, Reporter or Guest roles for projects, +or for groups to which the project belongs. + +Projects of nested subgroups of a selected template source cannot be used. + +Repository and database information that are copied over to each new project are +identical to the data exported with [GitLab's Project Import/Export](../project/settings/import_export.md). + +If you would like to set project templates at an instance level, please see [Custom instance-level project templates](../admin_area/custom_project_templates.md).
\ No newline at end of file diff --git a/doc/user/group/index.md b/doc/user/group/index.md index d673fa4d21a..5fea683a7fd 100644 --- a/doc/user/group/index.md +++ b/doc/user/group/index.md @@ -259,11 +259,17 @@ types with every project in a group. Learn more about [Group-level file templates](https://docs.gitlab.com/ee/user/group/index.html#group-level-file-templates-premium). +#### Group-level project templates **[PREMIUM]** + +Define project templates at a group-level by setting a group as a template source. +[Learn more about group-level project templates](custom_project_templates.md). + ### Advanced settings - **Projects**: view all projects within that group, add members to each project, access each project's settings, and remove any project from the same screen. - **Webhooks**: configure [webhooks](../project/integrations/webhooks.md) to your group. +- **Kubernetes cluster integration**: connect your GitLab group with [Kubernetes clusters](clusters/index.md). - **Audit Events**: view [Audit Events](https://docs.gitlab.com/ee/administration/audit_events.html#audit-events) for the group. **[STARTER ONLY]** -- **Pipelines quota**: keep track of the [pipeline quota](../admin_area/settings/continuous_integration.md) for the group +- **Pipelines quota**: keep track of the [pipeline quota](../admin_area/settings/continuous_integration.md) for the group. diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md index 8db36c4a0e8..943b0c693c0 100644 --- a/doc/user/group/subgroups/index.md +++ b/doc/user/group/subgroups/index.md @@ -167,7 +167,6 @@ Here's a list of what you can't do with subgroups: - [GitLab Pages](../../project/pages/index.md) are not currently working for projects hosted under a subgroup. That means that only projects hosted under the first parent group will work. -- Group level labels don't work in subgroups / sub projects - It is not possible to share a project with a group that's an ancestor of the group the project is in. That means you can only share as you walk down the hierarchy. For example, `group/subgroup01/project` **cannot** be shared diff --git a/doc/user/markdown.md b/doc/user/markdown.md index 6c6119a2691..debebd4c081 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -1022,7 +1022,7 @@ A link starting with a `/` is relative to the wiki root. [rouge]: http://rouge.jneen.net/ "Rouge website" [redcarpet]: https://github.com/vmg/redcarpet "Redcarpet website" [katex]: https://github.com/Khan/KaTeX "KaTeX website" -[katex-subset]: https://github.com/Khan/KaTeX/wiki/Function-Support-in-KaTeX "Macros supported by KaTeX" +[katex-subset]: https://katex.org/docs/supported.html "Macros supported by KaTeX" [asciidoctor-manual]: http://asciidoctor.org/docs/user-manual/#activating-stem-support "Asciidoctor user manual" [commonmarker]: https://github.com/gjtorikian/commonmarker [commonmark-spec]: https://spec.commonmark.org/current/ diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 6735710e2bb..6d05e2feeec 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -17,6 +17,11 @@ your account with Google Kubernetes Engine (GKE) so that you can [create new clusters](#adding-and-creating-a-new-gke-cluster-via-gitlab) from within GitLab, or provide the credentials to an [existing Kubernetes cluster](#adding-an-existing-kubernetes-cluster). +NOTE: **Note:** +From [GitLab 11.6](https://gitlab.com/gitlab-org/gitlab-ce/issues/34758) you +can also associate a Kubernetes cluster to your groups. Learn more about +[group Kubernetes clusters](../../group/clusters/index.md). + ## Adding and creating a new GKE cluster via GitLab TIP: **Tip:** @@ -92,13 +97,47 @@ To add an existing Kubernetes cluster to your project: the `ca.crt` contents here. - **Token** - GitLab authenticates against Kubernetes using service tokens, which are - scoped to a particular `namespace`. If you don't have a service token yet, - you can follow the - [Kubernetes documentation](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/) - to create one. You can also view or create service tokens in the - [Kubernetes dashboard](https://kubernetes.io/docs/tasks/access-application-cluster/web-ui-dashboard/) - (under **Config > Secrets**). **The account that will issue the service token - must have admin privileges on the cluster.** + scoped to a particular `namespace`. + **The token used should belong to a service account with + [`cluster-admin`](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) + privileges.** To create this service account: + + 1. Create a `gitlab` service account in the `default` namespace: + + ```bash + kubectl create -f - <<EOF + apiVersion: v1 + kind: ServiceAccount + metadata: + name: gitlab + namespace: default + EOF + ``` + 1. Create a cluster role binding to give the `gitlab` service account + `cluster-admin` privileges: + + ```bash + kubectl create -f - <<EOF + kind: ClusterRoleBinding + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: gitlab-cluster-admin + subjects: + - kind: ServiceAccount + name: gitlab + namespace: default + roleRef: + kind: ClusterRole + name: cluster-admin + apiGroup: rbac.authorization.k8s.io + EOF + ``` + NOTE: **Note:** + For GKE clusters, you will need the + `container.clusterRoleBindings.create` permission to create a cluster + role binding. You can follow the [Google Cloud + documentation](https://cloud.google.com/iam/docs/granting-changing-revoking-access) + to grant access. - **Project namespace** (optional) - You don't have to fill it in; by leaving it blank, GitLab will create one for you. Also: - Each project should have a unique namespace. @@ -142,8 +181,9 @@ Whether ABAC or RBAC is enabled, GitLab will create the necessary service accounts and privileges in order to install and run [GitLab managed applications](#installing-applications): -- A `gitlab` service account with `cluster-admin` privileges will be created in the - `default` namespace, which will be used by GitLab to manage the newly created cluster. +- If GitLab is creating the cluster, a `gitlab` service account with + `cluster-admin` privileges will be created in the `default` namespace, + which will be used by GitLab to manage the newly created cluster. - A project service account with [`edit` privileges](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) @@ -210,16 +250,18 @@ install it manually. ## Installing applications -GitLab provides a one-click install for various applications which will be -added directly to your configured cluster. Those applications are needed for -[Review Apps](../../../ci/review_apps/index.md) and [deployments](../../../ci/environments.md). +GitLab provides a one-click install for various applications which can +be added directly to your configured cluster. Those applications are +needed for [Review Apps](../../../ci/review_apps/index.md) and +[deployments](../../../ci/environments.md). NOTE: **Note:** With the exception of Knative, the applications will be installed in a dedicated namespace called `gitlab-managed-apps`. In case you have added an existing Kubernetes cluster with Tiller already installed, you should be careful as GitLab cannot -detect it. By installing it via the applications will result into having it -twice, which can lead to confusion during deployments. +detect it. In this event, installing Tiller via the applications will +result in the cluster having it twice. This can lead to confusion during +deployments. | Application | GitLab version | Description | Helm Chart | | ----------- | :------------: | ----------- | --------------- | @@ -238,13 +280,10 @@ by GitLab before installing any of the above applications. ## Getting the external IP address NOTE: **Note:** -You need a load balancer installed in your cluster in order to obtain the -external IP address with the following procedure. It can be deployed using the -[**Ingress** application](#installing-applications). - -NOTE: **Note:** -Knative will include its own load balancer in the form of [Istio](https://istio.io). -At this time, to determine the external IP address, you will need to follow the manual approach. +With the following procedure, a load balancer must be installed in your cluster +to obtain the external IP address. You can use either +[Ingress](#installing-applications), or Knative's own load balancer +([Istio](https://istio.io)) if using [Knative](#installing-applications). In order to publish your web application, you first need to find the external IP address associated to your load balancer. @@ -253,7 +292,7 @@ address associated to your load balancer. > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17052) in GitLab 10.6. -If you installed the Ingress [via the **Applications**](#installing-applications), +If you [installed Ingress or Knative](#installing-applications), you should see the Ingress IP address on this same page within a few minutes. If you don't see this, GitLab might not be able to determine the IP address of your ingress application in which case you should manually determine it. @@ -315,17 +354,13 @@ to reach your apps. This heavily depends on your domain provider, but in case you aren't sure, just create an A record with a wildcard host like `*.example.com.`. -## Setting the environment scope +## Setting the environment scope **[PREMIUM]** -NOTE: **Note:** -This is only available for [GitLab Premium][ee] where you can add more than -one Kubernetes cluster. - -When adding more than one Kubernetes clusters to your project, you need to -differentiate them with an environment scope. The environment scope associates -clusters and [environments](../../../ci/environments.md) in an 1:1 relationship -similar to how the -[environment-specific variables](../../../ci/variables/README.md#limiting-environment-scopes-of-variables) +When adding more than one Kubernetes clusters to your project, you need +to differentiate them with an environment scope. The environment scope +associates clusters with [environments](../../../ci/environments.md) +similar to how the [environment-specific +variables](../../../ci/variables/README.md#limiting-environment-scopes-of-variables) work. The default environment scope is `*`, which means all jobs, regardless of their @@ -400,12 +435,34 @@ GitLab CI/CD build environment. | `KUBE_NAMESPACE` | The Kubernetes namespace is auto-generated if not specified. The default value is `<project_name>-<project_id>`. You can overwrite it to use different one if needed, otherwise the `KUBE_NAMESPACE` variable will receive the default value. | | `KUBE_CA_PEM_FILE` | Path to a file containing PEM data. Only present if a custom CA bundle was specified. | | `KUBE_CA_PEM` | (**deprecated**) Raw PEM data. Only if a custom CA bundle was specified. | -| `KUBECONFIG` | Path to a file containing `kubeconfig` for this deployment. CA bundle would be embedded if specified. | +| `KUBECONFIG` | Path to a file containing `kubeconfig` for this deployment. CA bundle would be embedded if specified. This config also embeds the same token defined in `KUBE_TOKEN` so you likely will only need this variable. This variable name is also automatically picked up by `kubectl` so you won't actually need to reference it explicitly if using `kubectl`. | NOTE: **NOTE:** Prior to GitLab 11.5, `KUBE_TOKEN` was the Kubernetes token of the main service account of the cluster integration. +### Troubleshooting missing `KUBECONFIG` or `KUBE_TOKEN` + +GitLab will create a new service account specifically for your CI builds. The +new service account is created when the cluster is added to the project. +Sometimes there may be errors that cause the service account creation to fail. + +In such instances, your build will not be passed the `KUBECONFIG` or +`KUBE_TOKEN` variables and, if you are using Auto DevOps, your Auto DevOps +pipelines will no longer trigger a `production` deploy build. You will need to +check the [logs](../../../administration/logs.md) to debug why the service +account creation failed. + +A common reason for failure is that the token you gave GitLab did not have +[`cluster-admin`](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) +privileges as GitLab expects. + +Another common problem for why these variables are not being passed to your +builds is that they must have a matching +[`environment:name`](../../../ci/environments.md#defining-environments). If +your build has no `environment:name` set, it will not be passed the Kubernetes +credentials. + ## Enabling or disabling the Kubernetes cluster integration After you have successfully added your cluster information, you can enable the diff --git a/doc/user/project/clusters/serverless/img/install-knative.png b/doc/user/project/clusters/serverless/img/install-knative.png Binary files differindex dd576a9df35..a9fcc127240 100644 --- a/doc/user/project/clusters/serverless/img/install-knative.png +++ b/doc/user/project/clusters/serverless/img/install-knative.png diff --git a/doc/user/project/clusters/serverless/img/serverless-page.png b/doc/user/project/clusters/serverless/img/serverless-page.png Binary files differnew file mode 100644 index 00000000000..473ee801f10 --- /dev/null +++ b/doc/user/project/clusters/serverless/img/serverless-page.png diff --git a/doc/user/project/import/bitbucket_server.md b/doc/user/project/import/bitbucket_server.md index dc985e87a96..ebf87890cfd 100644 --- a/doc/user/project/import/bitbucket_server.md +++ b/doc/user/project/import/bitbucket_server.md @@ -27,7 +27,7 @@ request. 1. Bitbucket Server allows multiple levels of threading. GitLab import will collapse this into one discussion and quote part of the original comment. -1. Declined pull requests have unrecahable commits, which prevents the GitLab +1. Declined pull requests have unreachable commits, which prevents the GitLab importer from generating a proper diff. These pull requests will show up as empty changes. 1. Attachments in Markdown are currently not imported. diff --git a/doc/user/project/integrations/emails_on_push.md b/doc/user/project/integrations/emails_on_push.md index b050c83fb72..df320de7e16 100644 --- a/doc/user/project/integrations/emails_on_push.md +++ b/doc/user/project/integrations/emails_on_push.md @@ -1,20 +1,20 @@ # Enabling emails on push -By enabling this service, you will be able to receive email notifications for -every change that is pushed to your project. +By enabling this service, you will receive email notifications for every change +that is pushed to your project. -Navigate to the [Integrations page](project_services.md#accessing-the-project-services) -and select the **Emails on push** service to configure it. +From the [Integrations page](project_services.md#accessing-the-project-services) +select **Emails on push** service to activate and configure it. In the _Recipients_ area, provide a list of emails separated by spaces or newlines. -You can configure any of the following settings depending on your preference. +The following options are available: + **Push events** - Email will be triggered when a push event is received + **Tag push events** - Email will be triggered when a tag is created and pushed + **Send from committer** - Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. `user@gitlab.com`). + **Disable code diffs** - Don't include possibly sensitive code diffs in notification body. ---- - -![Email on push service settings](img/emails_on_push_service.png) +| Settings | Notification | +| --- | --- | +| ![Email on push service settings](img/emails_on_push_service.png) | ![Email on push notification](img/emails_on_push_email.png) | diff --git a/doc/user/project/integrations/img/emails_on_push_email.png b/doc/user/project/integrations/img/emails_on_push_email.png Binary files differnew file mode 100644 index 00000000000..406eeb18410 --- /dev/null +++ b/doc/user/project/integrations/img/emails_on_push_email.png diff --git a/doc/user/project/integrations/img/emails_on_push_service.png b/doc/user/project/integrations/img/emails_on_push_service.png Binary files differindex df301aa1eeb..84e42eca9a2 100644 --- a/doc/user/project/integrations/img/emails_on_push_service.png +++ b/doc/user/project/integrations/img/emails_on_push_service.png diff --git a/doc/user/project/merge_requests/img/merge_request_widget.png b/doc/user/project/merge_requests/img/merge_request_widget.png Binary files differindex 6c2317b29b5..58562fcb034 100644 --- a/doc/user/project/merge_requests/img/merge_request_widget.png +++ b/doc/user/project/merge_requests/img/merge_request_widget.png diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 5b54b6ecdd5..85d8d804133 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -259,6 +259,16 @@ all your changes will be available to preview by anyone with the Review Apps lin [Read more about Review Apps.](../../../ci/review_apps/index.md) +## Pipelines for merge requests + +When a developer updates a merge request, a pipeline should quickly report back +its result to the developer, but often pipelines take long time to complete +because general branch pipelines contain unnecessary jobs from the merge request standpoint. +You can customize a specific pipeline structure for merge requests in order to +speed the cycle up by running only important jobs. + +Learn more about [pipelines for merge requests](../../../ci/merge_request_pipelines/index.md). + ## Pipeline status in merge requests If you've set up [GitLab CI/CD](../../../ci/README.md) in your project, diff --git a/doc/user/project/merge_requests/resolve_conflicts.md b/doc/user/project/merge_requests/resolve_conflicts.md index ecbc8534eea..ccef2853e3f 100644 --- a/doc/user/project/merge_requests/resolve_conflicts.md +++ b/doc/user/project/merge_requests/resolve_conflicts.md @@ -1,15 +1,31 @@ -# Merge conflict resolution +# Merge request conflict resolution -> [Introduced][ce-5479] in GitLab 8.11. +Merge conflicts occur when two branches have different changes that cannot be +merged automatically. -When a merge request has conflicts, GitLab may provide the option to resolve -those conflicts in the GitLab UI. (See -[conflicts available for resolution](#conflicts-available-for-resolution) for -more information on when this is available.) If this is an option, you will see -a **resolve these conflicts** link in the merge request widget: +Git is able to automatically merge changes between branches in most cases, but +there are situations where Git will require your assistance to resolve the +conflicts manually. Typically, this is necessary when people change the same +parts of the same files. + +GitLab will prevent merge requests from being merged until all conflicts are +resolved. Conflicts can be resolved locally, or in many cases within GitLab +(see [conflicts available for resolution](#conflicts-available-for-resolution) +for information on when this is available). ![Merge request widget](img/merge_request_widget.png) +NOTE: **Note:** +GitLab resolves conflicts by creating a merge commit in the source branch that +is not automatically merged into the target branch. This allows the merge +commit to be reviewed and tested before the changes are merged, preventing +unintended changes entering the target branch without review or breaking the +build. + +## Resolve conflicts: interactive mode + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5479) in GitLab 8.11. + Clicking this will show a list of files with conflicts, with conflict sections highlighted: @@ -21,9 +37,9 @@ request into the source branch, resolving the conflicts using the options chosen. If the source branch is `feature` and the target branch is `master`, this is similar to performing `git checkout feature; git merge master` locally. -## Merge conflict editor +## Resolve conflicts: inline editor -> Introduced in GitLab 8.13. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6374) in GitLab 8.13. The merge conflict resolution editor allows for more complex merge conflicts, which require the user to manually modify a file in order to resolve a conflict, @@ -50,5 +66,3 @@ Additionally, GitLab does not detect conflicts in renames away from a path. For example, this will not create a conflict: on branch `a`, doing `git mv file1 file2`; on branch `b`, doing `git mv file1 file3`. Instead, both files will be present in the branch after the merge request is merged. - -[ce-5479]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5479 diff --git a/doc/user/project/pipelines/schedules.md b/doc/user/project/pipelines/schedules.md index 9daacc37994..051277dfe02 100644 --- a/doc/user/project/pipelines/schedules.md +++ b/doc/user/project/pipelines/schedules.md @@ -83,12 +83,12 @@ The next time a pipeline is scheduled, your credentials will be used. ![Schedules list](img/pipeline_schedules_ownership.png) -> **Note:** -When the owner of the schedule doesn't have the ability to create pipelines -anymore, due to e.g., being blocked or removed from the project, or lacking -the permission to run on protected branches or tags. When this happened, the -schedule is deactivated. Another user can take ownership and activate it, so -the schedule can be run again. +NOTE: **Note:** +If the owner of a pipeline schedule doesn't have the ability to create pipelines +on the target branch, the schedule will stop creating new pipelines. This can +happen if, for example, the owner is blocked or removed from the project, or +the target branch or tag is protected. In this case, someone with sufficient +privileges must take ownership of the schedule. ## Advanced admin configuration diff --git a/doc/user/project/repository/img/repository_cleanup.png b/doc/user/project/repository/img/repository_cleanup.png Binary files differnew file mode 100644 index 00000000000..2749392ffa4 --- /dev/null +++ b/doc/user/project/repository/img/repository_cleanup.png diff --git a/doc/user/project/repository/reducing_the_repo_size_using_git.md b/doc/user/project/repository/reducing_the_repo_size_using_git.md index d534c8cbe4b..672567a8d7d 100644 --- a/doc/user/project/repository/reducing_the_repo_size_using_git.md +++ b/doc/user/project/repository/reducing_the_repo_size_using_git.md @@ -1,43 +1,105 @@ # Reducing the repository size using Git A GitLab Enterprise Edition administrator can set a [repository size limit][admin-repo-size] -which will prevent you to exceed it. +which will prevent you from exceeding it. When a project has reached its size limit, you will not be able to push to it, create a new merge request, or merge existing ones. You will still be able to create new issues, and clone the project though. Uploading LFS objects will also be denied. -In order to lift these restrictions, the administrator of the GitLab instance -needs to increase the limit on the particular project that exceeded it or you -need to instruct Git to rewrite changes. - If you exceed the repository size limit, your first thought might be to remove -some data, make a new commit and push back to the repository. Unfortunately, -it's not so easy and that workflow won't work. Deleting files in a commit doesn't -actually reduce the size of the repo since the earlier commits and blobs are -still around. What you need to do is rewrite history with Git's -[`filter-branch` option][gitscm]. +some data, make a new commit and push back to the repository. Perhaps you can +move some blobs to LFS, or remove some old dependency updates from history. +Unfortunately, it's not so easy and that workflow won't work. Deleting files in +a commit doesn't actually reduce the size of the repo since the earlier commits +and blobs are still around. What you need to do is rewrite history with Git's +[`filter-branch` option][gitscm], or a tool like the [BFG Repo-Cleaner][bfg]. Note that even with that method, until `git gc` runs on the GitLab side, the -"removed" commits and blobs will still be around. And if a commit was ever -included in an MR, or if a build was run for a commit, or if a user commented -on it, it will be kept around too. So, in these cases the size will not decrease. - -The only fool proof way to actually decrease the repository size is to prune all -the unneeded stuff locally, and then create a new project on GitLab and start -using that instead. +"removed" commits and blobs will still be around. You also need to be able to +push the rewritten history to GitLab, which may be impossible if you've already +exceeded the maximum size limit. -With that being said, you can try reducing your repository size with the -following method. - -## Using `git filter-branch` to purge files +In order to lift these restrictions, the administrator of the GitLab instance +needs to increase the limit on the particular project that exceeded it, so it's +always better to spot that you're approaching the limit and act proactively to +stay underneath it. If you hit the limit, and your admin can't - or won't - +temporarily increase it for you, your only option is to prune all the unneeded +stuff locally, and then create a new project on GitLab and start using that +instead. + +If you can continue to use the original project, we recommend [using the +BFG Repo-Cleaner](#using-the-bfg-repo-cleaner). It's faster and simpler than +`git filter-branch`, and GitLab can use its account of what has changed to clean +up its own internal state, maximizing the space saved. > **Warning:** > Make sure to first make a copy of your repository since rewriting history will > purge the files and information you are about to delete. Also make sure to > inform any collaborators to not use `pull` after your changes, but use `rebase`. +> **Warning:** +> This process is not suitable for removing sensitive data like password or keys +> from your repository. Information about commits, including file content, is +> cached in the database, and will remain visible even after they have been +> removed from the repository. + +## Using the BFG Repo-Cleaner + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/19376) in GitLab 11.6. + +1. [Install BFG](https://rtyley.github.io/bfg-repo-cleaner/). + +1. Navigate to your repository: + + ``` + cd my_repository/ + ``` + +1. Change to the branch you want to remove the big file from: + + ``` + git checkout master + ``` + +1. Create a commit removing the large file from the branch, if it still exists: + + ``` + git rm path/to/big_file.mpg + git commit -m 'Remove unneeded large file' + ``` + +1. Rewrite history: + + ``` + bfg --delete-files path/to/big_file.mpg + ``` + + An object map file will be written to `object-id-map.old-new.txt`. Keep it + around - you'll need it for the final step! + +1. Force-push the changes to GitLab: + + ``` + git push --force-with-lease origin master + ``` + + If this step fails, someone has changed the `master` branch while you were + rewriting history. You could restore the branch and re-run BFG to preserve + their changes, or use `git push --force` to overwrite their changes. + +1. Navigate to **Project > Settings > Repository > Repository Cleanup**: + + ![Repository settings cleanup form](img/repository_cleanup.png) + + Upload the `object-id-map.old-new.txt` file and press **Start cleanup**. + This will remove any internal git references to the old commits, and run + `git gc` against the repository. You will receive an email once it has + completed. + +## Using `git filter-branch` + 1. Navigate to your repository: ``` @@ -70,11 +132,6 @@ following method. Your repository should now be below the size limit. -> **Note:** -> As an alternative to `filter-branch`, you can use the `bfg` tool with a -> command like: `bfg --delete-files path/to/big_file.mpg`. Read the -> [BFG Repo-Cleaner][bfg] documentation for more information. - [admin-repo-size]: https://docs.gitlab.com/ee/user/admin_area/settings/account_and_limit_settings.html#repository-size-limit [bfg]: https://rtyley.github.io/bfg-repo-cleaner/ [gitscm]: https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History#The-Nuclear-Option:-filter-branch diff --git a/doc/workflow/notifications.md b/doc/workflow/notifications.md index c590ac4b0ba..020aa73f809 100644 --- a/doc/workflow/notifications.md +++ b/doc/workflow/notifications.md @@ -64,6 +64,8 @@ Below is the table of events users can be notified of: |------------------------------|-------------------------------------------------------------------|------------------------------| | New SSH key added | User | Security email, always sent. | | New email added | User | Security email, always sent. | +| Email changed | User | Security email, always sent. | +| Password changed | User | Security email, always sent. | | New user created | User | Sent on user creation, except for omniauth (LDAP)| | User added to project | User | Sent when user is added to project | | Project access level changed | User | Sent when user project access level is changed | diff --git a/lib/api/access_requests.rb b/lib/api/access_requests.rb index cecff6d3b81..ee8dc822098 100644 --- a/lib/api/access_requests.rb +++ b/lib/api/access_requests.rb @@ -12,7 +12,7 @@ module API params do requires :id, type: String, desc: "The #{source_type} ID" end - resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource source_type.pluralize, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc "Gets a list of access requests for a #{source_type}." do detail 'This feature was introduced in GitLab 8.11.' success Entities::AccessRequester diff --git a/lib/api/api.rb b/lib/api/api.rb index 449faf5f8da..8abb24e6f69 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -7,8 +7,8 @@ module API LOG_FILENAME = Rails.root.join("log", "api_json.log") NO_SLASH_URL_PART_REGEX = %r{[^/]+} - PROJECT_ENDPOINT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze - COMMIT_ENDPOINT_REQUIREMENTS = PROJECT_ENDPOINT_REQUIREMENTS.merge(sha: NO_SLASH_URL_PART_REGEX).freeze + NAMESPACE_OR_PROJECT_REQUIREMENTS = { id: NO_SLASH_URL_PART_REGEX }.freeze + COMMIT_ENDPOINT_REQUIREMENTS = NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(sha: NO_SLASH_URL_PART_REGEX).freeze insert_before Grape::Middleware::Error, GrapeLogging::Middleware::RequestLogger, @@ -20,7 +20,8 @@ module API Gitlab::GrapeLogging::Loggers::RouteLogger.new, Gitlab::GrapeLogging::Loggers::UserLogger.new, Gitlab::GrapeLogging::Loggers::QueueDurationLogger.new, - Gitlab::GrapeLogging::Loggers::PerfLogger.new + Gitlab::GrapeLogging::Loggers::PerfLogger.new, + Gitlab::GrapeLogging::Loggers::CorrelationIdLogger.new ] allow_access_with_scope :api @@ -84,7 +85,6 @@ module API content_type :txt, "text/plain" # Ensure the namespace is right, otherwise we might load Grape::API::Helpers - helpers ::SentryHelper helpers ::API::Helpers helpers ::API::Helpers::CommonHelpers diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index 61357b3f1d6..af9b519ed9e 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -94,6 +94,7 @@ module API Gitlab::Auth::TokenNotFoundError, Gitlab::Auth::ExpiredError, Gitlab::Auth::RevokedError, + Gitlab::Auth::ImpersonationDisabled, Gitlab::Auth::InsufficientScopeError] base.__send__(:rescue_from, *error_classes, oauth2_bearer_token_error_handler) # rubocop:disable GitlabSecurity/PublicSend @@ -121,6 +122,11 @@ module API :invalid_token, "Token was revoked. You have to re-authorize from the user.") + when Gitlab::Auth::ImpersonationDisabled + Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new( + :invalid_token, + "Token is an impersonation token but impersonation was disabled.") + when Gitlab::Auth::InsufficientScopeError # FIXME: ForbiddenError (inherited from Bearer::Forbidden of Rack::Oauth2) # does not include WWW-Authenticate header, which breaks the standard. diff --git a/lib/api/award_emoji.rb b/lib/api/award_emoji.rb index c2abf9155f3..a1851ba3627 100644 --- a/lib/api/award_emoji.rb +++ b/lib/api/award_emoji.rb @@ -14,7 +14,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do AWARDABLES.each do |awardable_params| awardable_string = awardable_params[:type].pluralize awardable_id_string = "#{awardable_params[:type]}_#{awardable_params[:find_by]}" diff --git a/lib/api/badges.rb b/lib/api/badges.rb index ab670988f47..ba554e00a16 100644 --- a/lib/api/badges.rb +++ b/lib/api/badges.rb @@ -22,7 +22,7 @@ module API params do requires :id, type: String, desc: "The ID of a #{source_type}" end - resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource source_type.pluralize, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc "Gets a list of #{source_type} badges viewable by the authenticated user." do detail 'This feature was introduced in GitLab 10.6.' success Entities::Badge diff --git a/lib/api/boards.rb b/lib/api/boards.rb index c80e1c57864..b7c77730afb 100644 --- a/lib/api/boards.rb +++ b/lib/api/boards.rb @@ -16,7 +16,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do segment ':id/boards' do desc 'Get all project boards' do detail 'This feature was introduced in 8.13' diff --git a/lib/api/branches.rb b/lib/api/branches.rb index 2735d410c8e..e7e58ad0e66 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -6,7 +6,7 @@ module API class Branches < Grape::API include PaginationParams - BRANCH_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(branch: API::NO_SLASH_URL_PART_REGEX) + BRANCH_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(branch: API::NO_SLASH_URL_PART_REGEX) before { authorize! :download_code, user_project } @@ -20,7 +20,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a project repository branches' do success Entities::Branch end diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb index 99553d993ca..62c966e06b4 100644 --- a/lib/api/commit_statuses.rb +++ b/lib/api/commit_statuses.rb @@ -7,7 +7,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do include PaginationParams before { authenticate! } @@ -29,7 +29,7 @@ module API not_found!('Commit') unless user_project.commit(params[:sha]) - pipelines = user_project.pipelines.where(sha: params[:sha]) + pipelines = user_project.ci_pipelines.where(sha: params[:sha]) statuses = ::CommitStatus.where(pipeline: pipelines) statuses = statuses.latest unless to_boolean(params[:all]) statuses = statuses.where(ref: params[:ref]) if params[:ref].present? @@ -75,7 +75,7 @@ module API pipeline = @project.pipeline_for(ref, commit.sha) unless pipeline - pipeline = @project.pipelines.create!( + pipeline = @project.ci_pipelines.create!( source: :external, sha: commit.sha, ref: ref, diff --git a/lib/api/commits.rb b/lib/api/commits.rb index 337b92a6183..9d23daafe95 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -23,7 +23,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a project repository commits' do success Entities::Commit end diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb index ce35720d408..df6d2721977 100644 --- a/lib/api/deploy_keys.rb +++ b/lib/api/deploy_keys.rb @@ -31,7 +31,7 @@ module API params do requires :id, type: String, desc: 'The ID of the project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do before { authorize_admin_project } desc "Get a specific project's deploy keys" do diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb index 6747e2e5005..8706a971a1a 100644 --- a/lib/api/deployments.rb +++ b/lib/api/deployments.rb @@ -10,7 +10,7 @@ module API params do requires :id, type: String, desc: 'The project ID' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get all deployments of the project' do detail 'This feature was introduced in GitLab 8.11.' success Entities::Deployment diff --git a/lib/api/discussions.rb b/lib/api/discussions.rb index 39c6d28391d..91eb6a23701 100644 --- a/lib/api/discussions.rb +++ b/lib/api/discussions.rb @@ -17,7 +17,7 @@ module API params do requires :id, type: String, desc: "The ID of a #{parent_type}" end - resource parent_type.pluralize.to_sym, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource parent_type.pluralize.to_sym, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc "Get a list of #{noteable_type.to_s.downcase} discussions" do success Entities::Discussion end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index cff05643f3b..5dbfbb85e9e 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -145,7 +145,9 @@ module API expose :import_status # TODO: Use `expose_nil` once we upgrade the grape-entity gem - expose :import_error, if: lambda { |status, _ops| status.import_error } + expose :import_error, if: lambda { |project, _ops| project.import_state&.last_error } do |project| + project.import_state.last_error + end end class BasicProjectDetails < ProjectIdentity @@ -248,7 +250,10 @@ module API expose :creator_id expose :forked_from_project, using: Entities::BasicProjectDetails, if: lambda { |project, options| project.forked? } expose :import_status - expose :import_error, if: lambda { |_project, options| options[:user_can_admin_project] } + + expose :import_error, if: lambda { |_project, options| options[:user_can_admin_project] } do |project| + project.import_state&.last_error + end expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:current_user]) } expose :runners_token, if: lambda { |_project, options| options[:user_can_admin_project] } @@ -710,6 +715,10 @@ module API expose :diff_refs, using: Entities::DiffRefs + # Allow the status of a rebase to be determined + expose :merge_error + expose :rebase_in_progress?, as: :rebase_in_progress, if: -> (_, options) { options[:include_rebase_in_progress] } + expose :diverged_commits_count, as: :diverged_commits_count, if: -> (_, options) { options[:include_diverged_commits_count] } def build_available?(options) diff --git a/lib/api/environments.rb b/lib/api/environments.rb index c64217a6977..633f24d3c9a 100644 --- a/lib/api/environments.rb +++ b/lib/api/environments.rb @@ -11,7 +11,7 @@ module API params do requires :id, type: String, desc: 'The project ID' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get all environments of the project' do detail 'This feature was introduced in GitLab 8.11.' success Entities::Environment diff --git a/lib/api/events.rb b/lib/api/events.rb index 6e0b508be19..44dae57770d 100644 --- a/lib/api/events.rb +++ b/lib/api/events.rb @@ -97,7 +97,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc "List a Project's visible events" do success Entities::Event end diff --git a/lib/api/files.rb b/lib/api/files.rb index bcd2cd48a45..ca59d330e1c 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -2,7 +2,9 @@ module API class Files < Grape::API - FILE_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(file_path: API::NO_SLASH_URL_PART_REGEX) + include APIGuard + + FILE_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(file_path: API::NO_SLASH_URL_PART_REGEX) # Prevents returning plain/text responses for files with .txt extension after_validation { content_type "application/json" } @@ -79,6 +81,8 @@ module API requires :id, type: String, desc: 'The project ID' end resource :projects, requirements: FILE_ENDPOINT_REQUIREMENTS do + allow_access_with_scope :read_repository, if: -> (request) { request.get? || request.head? } + desc 'Get raw file metadata from repository' params do requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb' diff --git a/lib/api/group_boards.rb b/lib/api/group_boards.rb index dc30e868e2e..9a20ee8c8b9 100644 --- a/lib/api/group_boards.rb +++ b/lib/api/group_boards.rb @@ -19,7 +19,7 @@ module API requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do segment ':id/boards' do desc 'Find a group board' do detail 'This feature was introduced in 10.6' diff --git a/lib/api/group_milestones.rb b/lib/api/group_milestones.rb index b36436dbf43..d4287e4a7c4 100644 --- a/lib/api/group_milestones.rb +++ b/lib/api/group_milestones.rb @@ -12,7 +12,7 @@ module API params do requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a list of group milestones' do success Entities::Milestone end diff --git a/lib/api/group_variables.rb b/lib/api/group_variables.rb index ae7241e9a30..3f048e0dc56 100644 --- a/lib/api/group_variables.rb +++ b/lib/api/group_variables.rb @@ -11,7 +11,7 @@ module API requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get group-level variables' do success Entities::Variable end diff --git a/lib/api/groups.rb b/lib/api/groups.rb index b3d10721692..626a2008dee 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -140,7 +140,7 @@ module API params do requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Update a group. Available only for users who can administrate groups.' do success Entities::Group end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 9fda73d5b92..2cceb2ec798 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -368,10 +368,10 @@ module API end def handle_api_exception(exception) - if sentry_enabled? && report_exception?(exception) + if report_exception?(exception) define_params_for_grape_middleware - sentry_context - Raven.capture_exception(exception, extra: params) + Gitlab::Sentry.context(current_user) + Gitlab::Sentry.track_acceptable_exception(exception, extra: params) end # lifted from https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb#L60 diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 491b5085bb8..dac700482b4 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -101,7 +101,7 @@ module API params do requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a list of group issues' do success Entities::IssueBasic end @@ -128,7 +128,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do include TimeTrackingEndpoints desc 'Get a list of project issues' do diff --git a/lib/api/job_artifacts.rb b/lib/api/job_artifacts.rb index 2229cbcd9d4..7c2d8ff11bf 100644 --- a/lib/api/job_artifacts.rb +++ b/lib/api/job_artifacts.rb @@ -14,7 +14,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Download the artifacts archive from a job' do detail 'This feature was introduced in GitLab 8.10' end diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb index 697555c9605..80a5cbd6b19 100644 --- a/lib/api/jobs.rb +++ b/lib/api/jobs.rb @@ -9,7 +9,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do helpers do params :optional_scope do optional :scope, types: [String, Array[String]], desc: 'The scope of builds to show', @@ -56,7 +56,7 @@ module API end # rubocop: disable CodeReuse/ActiveRecord get ':id/pipelines/:pipeline_id/jobs' do - pipeline = user_project.pipelines.find(params[:pipeline_id]) + pipeline = user_project.ci_pipelines.find(params[:pipeline_id]) builds = pipeline.builds builds = filter_builds(builds, params[:scope]) builds = builds.preload(:job_artifacts_archive, :job_artifacts, project: [:namespace]) diff --git a/lib/api/labels.rb b/lib/api/labels.rb index 28555454307..2e676b0aa6b 100644 --- a/lib/api/labels.rb +++ b/lib/api/labels.rb @@ -9,7 +9,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get all labels of the project' do success Entities::Label end diff --git a/lib/api/members.rb b/lib/api/members.rb index a8f67be3463..461ffe71a62 100644 --- a/lib/api/members.rb +++ b/lib/api/members.rb @@ -12,7 +12,7 @@ module API params do requires :id, type: String, desc: "The #{source_type} ID" end - resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource source_type.pluralize, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Gets a list of group or project members viewable by the authenticated user.' do success Entities::Member end diff --git a/lib/api/merge_request_diffs.rb b/lib/api/merge_request_diffs.rb index e4fb890960a..6ad30aa56e0 100644 --- a/lib/api/merge_request_diffs.rb +++ b/lib/api/merge_request_diffs.rb @@ -10,7 +10,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a list of merge request diff versions' do detail 'This feature was introduced in GitLab 8.12.' success Entities::MergeRequestDiff diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 16f07f16387..8c1951cc535 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -74,6 +74,19 @@ module API options end + def authorize_push_to_merge_request!(merge_request) + forbidden!('Source branch does not exist') unless + merge_request.source_branch_exists? + + user_access = Gitlab::UserAccess.new( + current_user, + project: merge_request.source_project + ) + + forbidden!('Cannot push to source branch') unless + user_access.can_push_to_branch?(merge_request.source_branch) + end + params :merge_requests_params do optional :state, type: String, values: %w[opened closed locked merged all], default: 'all', desc: 'Return opened, closed, locked, merged, or all merge requests' @@ -122,7 +135,7 @@ module API params do requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a list of group merge requests' do success Entities::MergeRequestBasic end @@ -141,7 +154,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do include TimeTrackingEndpoints helpers do @@ -239,6 +252,7 @@ module API requires :merge_request_iid, type: Integer, desc: 'The IID of a merge request' optional :render_html, type: Boolean, desc: 'Returns the description and title rendered HTML' optional :include_diverged_commits_count, type: Boolean, desc: 'Returns the commits count behind the target branch' + optional :include_rebase_in_progress, type: Boolean, desc: 'Returns whether a rebase operation is ongoing ' end desc 'Get a single merge request' do success Entities::MergeRequest @@ -246,7 +260,13 @@ module API get ':id/merge_requests/:merge_request_iid' do merge_request = find_merge_request_with_access(params[:merge_request_iid]) - present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project, render_html: params[:render_html], include_diverged_commits_count: params[:include_diverged_commits_count] + present merge_request, + with: Entities::MergeRequest, + current_user: current_user, + project: user_project, + render_html: params[:render_html], + include_diverged_commits_count: params[:include_diverged_commits_count], + include_rebase_in_progress: params[:include_rebase_in_progress] end desc 'Get the participants of a merge request' do @@ -378,6 +398,19 @@ module API .cancel(merge_request) end + desc 'Rebase the merge request against its target branch' do + detail 'This feature was added in GitLab 11.6' + end + put ':id/merge_requests/:merge_request_iid/rebase' do + merge_request = find_project_merge_request(params[:merge_request_iid]) + + authorize_push_to_merge_request!(merge_request) + + RebaseWorker.perform_async(merge_request.id, current_user.id) + + status :accepted + end + desc 'List issues that will be closed on merge' do success Entities::MRNote end diff --git a/lib/api/namespaces.rb b/lib/api/namespaces.rb index 76639fbb031..3cc09f6ac3f 100644 --- a/lib/api/namespaces.rb +++ b/lib/api/namespaces.rb @@ -6,20 +6,35 @@ module API before { authenticate! } + helpers do + params :optional_list_params_ee do + # EE::API::Namespaces would override this helper + end + + # EE::API::Namespaces would override this method + def custom_namespace_present_options + {} + end + end + resource :namespaces do desc 'Get a namespaces list' do success Entities::Namespace end params do optional :search, type: String, desc: "Search query for namespaces" + use :pagination + use :optional_list_params_ee end get do namespaces = current_user.admin ? Namespace.all : current_user.namespaces namespaces = namespaces.search(params[:search]) if params[:search].present? - present paginate(namespaces), with: Entities::Namespace, current_user: current_user + options = { with: Entities::Namespace, current_user: current_user } + + present paginate(namespaces), options.reverse_merge(custom_namespace_present_options) end desc 'Get a namespace by ID' do @@ -28,7 +43,7 @@ module API params do requires :id, type: String, desc: "Namespace's ID or path" end - get ':id' do + get ':id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do present user_namespace, with: Entities::Namespace, current_user: current_user end end diff --git a/lib/api/notes.rb b/lib/api/notes.rb index 9f323b87baf..1bdf7aeb119 100644 --- a/lib/api/notes.rb +++ b/lib/api/notes.rb @@ -16,7 +16,7 @@ module API params do requires :id, type: String, desc: "The ID of a #{parent_type}" end - resource parent_type.pluralize.to_sym, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource parent_type.pluralize.to_sym, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do noteables_str = noteable_type.to_s.underscore.pluralize desc "Get a list of #{noteable_type.to_s.downcase} notes" do diff --git a/lib/api/notification_settings.rb b/lib/api/notification_settings.rb index 4d9a4629268..8cb46bd3ad6 100644 --- a/lib/api/notification_settings.rb +++ b/lib/api/notification_settings.rb @@ -58,7 +58,7 @@ module API params do requires :id, type: String, desc: "The #{source_type} ID" end - resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource source_type.pluralize, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc "Get #{source_type} level notification level settings, defaults to Global" do detail 'This feature was introduced in GitLab 8.12' success Entities::NotificationSetting diff --git a/lib/api/pages_domains.rb b/lib/api/pages_domains.rb index c9ad47e0f0d..78442f465bd 100644 --- a/lib/api/pages_domains.rb +++ b/lib/api/pages_domains.rb @@ -4,7 +4,7 @@ module API class PagesDomains < Grape::API include PaginationParams - PAGES_DOMAINS_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(domain: API::NO_SLASH_URL_PART_REGEX) + PAGES_DOMAINS_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(domain: API::NO_SLASH_URL_PART_REGEX) before do authenticate! @@ -54,7 +54,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do before do require_pages_enabled! end diff --git a/lib/api/pipeline_schedules.rb b/lib/api/pipeline_schedules.rb index ed0a38b9d70..47b711917e2 100644 --- a/lib/api/pipeline_schedules.rb +++ b/lib/api/pipeline_schedules.rb @@ -9,7 +9,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get all pipeline schedules' do success Entities::PipelineSchedule end diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb index cba1e3a6684..7a7b23d2bbb 100644 --- a/lib/api/pipelines.rb +++ b/lib/api/pipelines.rb @@ -9,7 +9,7 @@ module API params do requires :id, type: String, desc: 'The project ID' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get all Pipelines of the project' do detail 'This feature was introduced in GitLab 8.11.' success Entities::PipelineBasic @@ -130,7 +130,7 @@ module API helpers do def pipeline - @pipeline ||= user_project.pipelines.find(params[:pipeline_id]) + @pipeline ||= user_project.ci_pipelines.find(params[:pipeline_id]) end end end diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb index 4af4c6ac593..0e7576c9243 100644 --- a/lib/api/project_hooks.rb +++ b/lib/api/project_hooks.rb @@ -29,7 +29,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get project hooks' do success Entities::ProjectHook end diff --git a/lib/api/project_import.rb b/lib/api/project_import.rb index cbfa0c5bc1c..c64ec2fcc95 100644 --- a/lib/api/project_import.rb +++ b/lib/api/project_import.rb @@ -23,7 +23,7 @@ module API forbidden! unless Gitlab::CurrentSettings.import_sources.include?('gitlab_project') end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do params do requires :path, type: String, desc: 'The new project path and name' requires :file, type: File, desc: 'The project export file to be imported' diff --git a/lib/api/project_milestones.rb b/lib/api/project_milestones.rb index c7137ba5217..da31bcb8dac 100644 --- a/lib/api/project_milestones.rb +++ b/lib/api/project_milestones.rb @@ -12,7 +12,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a list of project milestones' do success Entities::Milestone end diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb index f3a1b73b153..a607df411a6 100644 --- a/lib/api/project_snippets.rb +++ b/lib/api/project_snippets.rb @@ -9,7 +9,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do helpers do def handle_project_member_errors(errors) if errors[:project_access].any? diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 0a914f9012e..f5d21d8923f 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -128,7 +128,7 @@ module API end end - resource :users, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :users, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a user projects' do success Entities::BasicProjectDetails end @@ -224,7 +224,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a single project' do success Entities::ProjectWithAccess end diff --git a/lib/api/protected_branches.rb b/lib/api/protected_branches.rb index 47752f40e58..5af43448727 100644 --- a/lib/api/protected_branches.rb +++ b/lib/api/protected_branches.rb @@ -4,14 +4,14 @@ module API class ProtectedBranches < Grape::API include PaginationParams - BRANCH_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(name: API::NO_SLASH_URL_PART_REGEX) + BRANCH_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(name: API::NO_SLASH_URL_PART_REGEX) before { authorize_admin_project } params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc "Get a project's protected branches" do success Entities::ProtectedBranch end diff --git a/lib/api/protected_tags.rb b/lib/api/protected_tags.rb index ed1c5f0cc05..ee13473c848 100644 --- a/lib/api/protected_tags.rb +++ b/lib/api/protected_tags.rb @@ -4,14 +4,14 @@ module API class ProtectedTags < Grape::API include PaginationParams - TAG_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(name: API::NO_SLASH_URL_PART_REGEX) + TAG_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(name: API::NO_SLASH_URL_PART_REGEX) before { authorize_admin_project } params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc "Get a project's protected tags" do detail 'This feature was introduced in GitLab 11.3.' success Entities::ProtectedTag diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index dc844c0bd27..32e05d84491 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -11,7 +11,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do helpers do def handle_project_member_errors(errors) if errors[:project_access].any? diff --git a/lib/api/resource_label_events.rb b/lib/api/resource_label_events.rb index b6fbe8c0235..0c328f7268e 100644 --- a/lib/api/resource_label_events.rb +++ b/lib/api/resource_label_events.rb @@ -16,7 +16,7 @@ module API params do requires :id, type: String, desc: "The ID of a #{parent_type}" end - resource parent_type.pluralize.to_sym, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource parent_type.pluralize.to_sym, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc "Get a list of #{eventable_type.to_s.downcase} resource label events" do success Entities::ResourceLabelEvent detail 'This feature was introduced in 11.3' diff --git a/lib/api/runner.rb b/lib/api/runner.rb index 2f15f3a7d76..c60d25b88cb 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -19,7 +19,6 @@ module API 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 - # rubocop: disable CodeReuse/ActiveRecord post '/' do attributes = attributes_for_keys([:description, :active, :locked, :run_untagged, :tag_list, :maximum_timeout]) .merge(get_runner_details_from_request) @@ -28,10 +27,10 @@ module API if runner_registration_token_valid? # Create shared runner. Requires admin access attributes.merge(runner_type: :instance_type) - elsif project = Project.find_by(runners_token: params[:token]) + elsif project = Project.find_by_runners_token(params[:token]) # Create a specific runner for the project attributes.merge(runner_type: :project_type, projects: [project]) - elsif group = Group.find_by(runners_token: params[:token]) + elsif group = Group.find_by_runners_token(params[:token]) # Create a specific runner for the group attributes.merge(runner_type: :group_type, groups: [group]) else @@ -46,7 +45,6 @@ module API render_validation_error!(runner) end end - # rubocop: enable CodeReuse/ActiveRecord desc 'Deletes a registered Runner' do http_codes [[204, 'Runner was deleted'], [403, 'Forbidden']] diff --git a/lib/api/runners.rb b/lib/api/runners.rb index ce70460af11..f72b33605a7 100644 --- a/lib/api/runners.rb +++ b/lib/api/runners.rb @@ -126,7 +126,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do before { authorize_admin_project } desc 'Get runners available for project' do diff --git a/lib/api/search.rb b/lib/api/search.rb index 12d97dcfe7f..f5db692afe5 100644 --- a/lib/api/search.rb +++ b/lib/api/search.rb @@ -35,12 +35,7 @@ module API end def process_results(results) - case params[:scope] - when 'blobs', 'wiki_blobs' - paginate(results).map { |blob| blob[1] } - else - paginate(results) - end + paginate(results) end def snippets? @@ -70,7 +65,7 @@ module API end end - resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Search on GitLab' do detail 'This feature was introduced in GitLab 10.5.' end @@ -89,7 +84,7 @@ module API end end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Search on GitLab' do detail 'This feature was introduced in GitLab 10.5.' end diff --git a/lib/api/services.rb b/lib/api/services.rb index 1cb3b8a7277..d60f0f5f08d 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -763,7 +763,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do before { authenticate! } before { authorize_admin_project } @@ -842,7 +842,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc "Trigger a slash command for #{service_slug}" do detail 'Added in GitLab 8.13' end diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb index 077e9373ac4..74ad3c35a61 100644 --- a/lib/api/subscriptions.rb +++ b/lib/api/subscriptions.rb @@ -14,7 +14,7 @@ module API requires :id, type: String, desc: 'The ID of a project' requires :subscribable_id, type: String, desc: 'The ID of a resource' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do subscribable_types.each do |type, finder| type_singularized = type.singularize entity_class = Entities.const_get(type_singularized.camelcase) diff --git a/lib/api/tags.rb b/lib/api/tags.rb index f739eacf9ba..b18eec7d796 100644 --- a/lib/api/tags.rb +++ b/lib/api/tags.rb @@ -4,14 +4,14 @@ module API class Tags < Grape::API include PaginationParams - TAG_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(tag_name: API::NO_SLASH_URL_PART_REGEX) + TAG_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(tag_name: API::NO_SLASH_URL_PART_REGEX) before { authorize! :download_code, user_project } params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a project repository tags' do success Entities::Tag end diff --git a/lib/api/templates.rb b/lib/api/templates.rb index 8dab19d50c2..51f357d9477 100644 --- a/lib/api/templates.rb +++ b/lib/api/templates.rb @@ -82,7 +82,7 @@ module API params do requires :name, type: String, desc: 'The name of the template' end - get "templates/#{template_type}/:name" do + get "templates/#{template_type}/:name", requirements: { name: /[\w\.-]+/ } do finder = TemplateFinder.build(template_type, nil, name: declared(params)[:name]) new_template = finder.execute diff --git a/lib/api/todos.rb b/lib/api/todos.rb index ed2cf2cc31b..d2c8cf7c1aa 100644 --- a/lib/api/todos.rb +++ b/lib/api/todos.rb @@ -14,7 +14,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do ISSUABLE_TYPES.each do |type, finder| type_id_str = "#{type.singularize}_iid".to_sym diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb index f784c857883..3ce1529f259 100644 --- a/lib/api/triggers.rb +++ b/lib/api/triggers.rb @@ -7,7 +7,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Trigger a GitLab project pipeline' do success Entities::Pipeline end diff --git a/lib/api/variables.rb b/lib/api/variables.rb index c844ba321ed..f7cae2251c2 100644 --- a/lib/api/variables.rb +++ b/lib/api/variables.rb @@ -11,7 +11,7 @@ module API requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get project variables' do success Entities::Variable end diff --git a/lib/api/wikis.rb b/lib/api/wikis.rb index 24746f4efc6..302b2797a34 100644 --- a/lib/api/wikis.rb +++ b/lib/api/wikis.rb @@ -22,7 +22,7 @@ module API end end - resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a list of wiki pages' do success Entities::WikiPageBasic end @@ -103,7 +103,7 @@ module API requires :file, type: ::API::Validations::Types::SafeFile, desc: 'The attachment file to be uploaded' optional :branch, type: String, desc: 'The name of the branch' end - post ":id/wikis/attachments", requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + post ":id/wikis/attachments", requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do authorize! :create_wiki, user_project result = ::Wikis::CreateAttachmentService.new(user_project, diff --git a/lib/banzai/filter/spaced_link_filter.rb b/lib/banzai/filter/spaced_link_filter.rb index a27f1d46863..c6a3a763c23 100644 --- a/lib/banzai/filter/spaced_link_filter.rb +++ b/lib/banzai/filter/spaced_link_filter.rb @@ -17,6 +17,9 @@ module Banzai # This is a small extension to the CommonMark spec. If they start allowing # spaces in urls, we could then remove this filter. # + # Note: Filter::SanitizationFilter should always be run sometime after this filter + # to prevent XSS attacks + # class SpacedLinkFilter < HTML::Pipeline::Filter include ActionView::Helpers::TagHelper diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index be75e34a673..96bea7ca935 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -12,13 +12,16 @@ module Banzai def self.filters @filters ||= FilterArray[ Filter::PlantumlFilter, + + # Must always be before the SanitizationFilter to prevent XSS attacks + Filter::SpacedLinkFilter, + Filter::SanitizationFilter, Filter::SyntaxHighlightFilter, Filter::MathFilter, Filter::ColorFilter, Filter::MermaidFilter, - Filter::SpacedLinkFilter, Filter::VideoLinkFilter, Filter::ImageLazyLoadFilter, Filter::ImageLinkFilter, diff --git a/lib/extracts_path.rb b/lib/extracts_path.rb index 655278da711..b2c8d46ede1 100644 --- a/lib/extracts_path.rb +++ b/lib/extracts_path.rb @@ -110,11 +110,6 @@ module ExtractsPath # resolved (e.g., when a user inserts an invalid path or ref). # rubocop:disable Gitlab/ModuleWithInstanceVariables def assign_ref_vars - # assign allowed options - allowed_options = ["filter_ref"] - @options = params.select {|key, value| allowed_options.include?(key) && !value.blank? } - @options = HashWithIndifferentAccess.new(@options) - @id = get_id @ref, @path = extract_ref(@id) @repo = @project.repository diff --git a/lib/gitlab/auth/request_authenticator.rb b/lib/gitlab/auth/request_authenticator.rb index cb9f2582936..176766d1a8b 100644 --- a/lib/gitlab/auth/request_authenticator.rb +++ b/lib/gitlab/auth/request_authenticator.rb @@ -13,12 +13,18 @@ module Gitlab @request = request end - def user - find_sessionless_user || find_user_from_warden + def user(request_formats) + request_formats.each do |format| + user = find_sessionless_user(format) + + return user if user + end + + find_user_from_warden end - def find_sessionless_user - find_user_from_access_token || find_user_from_feed_token + def find_sessionless_user(request_format) + find_user_from_web_access_token(request_format) || find_user_from_feed_token(request_format) rescue Gitlab::Auth::AuthenticationError nil end diff --git a/lib/gitlab/auth/user_auth_finders.rb b/lib/gitlab/auth/user_auth_finders.rb index c304adc64db..a5efe33bdc6 100644 --- a/lib/gitlab/auth/user_auth_finders.rb +++ b/lib/gitlab/auth/user_auth_finders.rb @@ -7,6 +7,7 @@ module Gitlab TokenNotFoundError = Class.new(AuthenticationError) ExpiredError = Class.new(AuthenticationError) RevokedError = Class.new(AuthenticationError) + ImpersonationDisabled = Class.new(AuthenticationError) UnauthorizedError = Class.new(AuthenticationError) class InsufficientScopeError < AuthenticationError @@ -27,8 +28,8 @@ module Gitlab current_request.env['warden']&.authenticate if verified_request? end - def find_user_from_feed_token - return unless rss_request? || ics_request? + def find_user_from_feed_token(request_format) + return unless valid_rss_format?(request_format) # NOTE: feed_token was renamed from rss_token but both needs to be supported because # users might have already added the feed to their RSS reader before the rename @@ -38,6 +39,17 @@ module Gitlab User.find_by_feed_token(token) || raise(UnauthorizedError) end + # We only allow Private Access Tokens with `api` scope to be used by web + # requests on RSS feeds or ICS files for backwards compatibility. + # It is also used by GraphQL/API requests. + def find_user_from_web_access_token(request_format) + return unless access_token && valid_web_access_format?(request_format) + + validate_access_token!(scopes: [:api]) + + access_token.user || raise(UnauthorizedError) + end + def find_user_from_access_token return unless access_token @@ -56,6 +68,8 @@ module Gitlab raise ExpiredError when AccessTokenValidationService::REVOKED raise RevokedError + when AccessTokenValidationService::IMPERSONATION_DISABLED + raise ImpersonationDisabled end end @@ -109,6 +123,26 @@ module Gitlab @current_request ||= ensure_action_dispatch_request(request) end + def valid_web_access_format?(request_format) + case request_format + when :rss + rss_request? + when :ics + ics_request? + when :api + api_request? + end + end + + def valid_rss_format?(request_format) + case request_format + when :rss + rss_request? + when :ics + ics_request? + end + end + def rss_request? current_request.path.ends_with?('.atom') || current_request.format.atom? end @@ -116,6 +150,10 @@ module Gitlab def ics_request? current_request.path.ends_with?('.ics') || current_request.format.ics? end + + def api_request? + current_request.path.starts_with?("/api/") + end end end end diff --git a/lib/gitlab/background_migration/backfill_project_fullpath_in_repo_config.rb b/lib/gitlab/background_migration/backfill_project_fullpath_in_repo_config.rb new file mode 100644 index 00000000000..29fa0f18448 --- /dev/null +++ b/lib/gitlab/background_migration/backfill_project_fullpath_in_repo_config.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # This module is used to write the full path of all projects to + # the git repository config file. + # Storing the full project path in the git config allows admins to + # easily identify a project when it is using hashed storage. + module BackfillProjectFullpathInRepoConfig + OrphanedNamespaceError = Class.new(StandardError) + + module Storage + # Class that returns the disk path for a project using hashed storage + class HashedProject + attr_accessor :project + + ROOT_PATH_PREFIX = '@hashed' + + def initialize(project) + @project = project + end + + def disk_path + "#{ROOT_PATH_PREFIX}/#{disk_hash[0..1]}/#{disk_hash[2..3]}/#{disk_hash}" + end + + def disk_hash + @disk_hash ||= Digest::SHA2.hexdigest(project.id.to_s) if project.id + end + end + + # Class that returns the disk path for a project using legacy storage + class LegacyProject + attr_accessor :project + + def initialize(project) + @project = project + end + + def disk_path + project.full_path + end + end + end + + # Concern used by Project and Namespace to determine the full + # route to the project + module Routable + extend ActiveSupport::Concern + + def full_path + @full_path ||= build_full_path + end + + def build_full_path + return path unless has_parent? + + raise OrphanedNamespaceError if parent.nil? + + parent.full_path + '/' + path + end + + def has_parent? + read_attribute(association(:parent).reflection.foreign_key) + end + end + + # Class used to interact with repository using Gitaly + class Repository + attr_reader :storage + + def initialize(storage, relative_path) + @storage = storage + @relative_path = relative_path + end + + def gitaly_repository + Gitaly::Repository.new(storage_name: @storage, relative_path: @relative_path) + end + end + + # Namespace can be a user or group. It can be the root or a + # child of another namespace. + class Namespace < ActiveRecord::Base + self.table_name = 'namespaces' + self.inheritance_column = nil + + include Routable + + belongs_to :parent, class_name: 'Namespace', inverse_of: 'namespaces' + has_many :projects, inverse_of: :parent + has_many :namespaces, inverse_of: :parent + end + + # Project is where the repository (etc.) is stored + class Project < ActiveRecord::Base + self.table_name = 'projects' + + include Routable + include EachBatch + + FULLPATH_CONFIG_KEY = 'gitlab.fullpath' + + belongs_to :parent, class_name: 'Namespace', foreign_key: :namespace_id, inverse_of: 'projects' + delegate :disk_path, to: :storage + + def add_fullpath_config + entries = { FULLPATH_CONFIG_KEY => full_path } + + repository_service.set_config(entries) + end + + def remove_fullpath_config + repository_service.delete_config([FULLPATH_CONFIG_KEY]) + end + + def cleanup_repository + repository_service.cleanup + end + + def storage + @storage ||= + if hashed_storage? + Storage::HashedProject.new(self) + else + Storage::LegacyProject.new(self) + end + end + + def hashed_storage? + self.storage_version && self.storage_version >= 1 + end + + def repository + @repository ||= Repository.new(repository_storage, disk_path + '.git') + end + + def repository_service + @repository_service ||= Gitlab::GitalyClient::RepositoryService.new(repository) + end + end + + # Base class for Up and Down migration classes + class BackfillFullpathMigration + RETRY_DELAY = 15.minutes + MAX_RETRIES = 2 + + # Base class for retrying one project + class BaseRetryOne + def perform(project_id, retry_count) + project = Project.find(project_id) + + return unless project + + migration_class.new.safe_perform_one(project, retry_count) + end + end + + def perform(start_id, end_id) + Project.includes(:parent).where(id: start_id..end_id).each do |project| + safe_perform_one(project) + end + end + + def safe_perform_one(project, retry_count = 0) + perform_one(project) + rescue GRPC::NotFound, GRPC::InvalidArgument, OrphanedNamespaceError + nil + rescue GRPC::BadStatus + schedule_retry(project, retry_count + 1) if retry_count < MAX_RETRIES + end + + def schedule_retry(project, retry_count) + BackgroundMigrationWorker.perform_in(RETRY_DELAY, self.class::RetryOne.name, [project.id, retry_count]) + end + end + + # Class to add the fullpath to the git repo config + class Up < BackfillFullpathMigration + # Class used to retry + class RetryOne < BaseRetryOne + def migration_class + Up + end + end + + def perform_one(project) + project.cleanup_repository + project.add_fullpath_config + end + end + + # Class to rollback adding the fullpath to the git repo config + class Down < BackfillFullpathMigration + # Class used to retry + class RetryOne < BaseRetryOne + def migration_class + Down + end + end + + def perform_one(project) + project.cleanup_repository + project.remove_fullpath_config + end + end + end + end +end diff --git a/lib/gitlab/background_migration/encrypt_columns.rb b/lib/gitlab/background_migration/encrypt_columns.rb index bd5f12276ab..b9ad8267e37 100644 --- a/lib/gitlab/background_migration/encrypt_columns.rb +++ b/lib/gitlab/background_migration/encrypt_columns.rb @@ -5,15 +5,17 @@ module Gitlab # EncryptColumn migrates data from an unencrypted column - `foo`, say - to # an encrypted column - `encrypted_foo`, say. # + # To avoid depending on a particular version of the model in app/, add a + # model to `lib/gitlab/background_migration/models/encrypt_columns` and use + # it in the migration that enqueues the jobs, so code can be shared. + # # For this background migration to work, the table that is migrated _has_ to # have an `id` column as the primary key. Additionally, the encrypted column # should be managed by attr_encrypted, and map to an attribute with the same # name as the unencrypted column (i.e., the unencrypted column should be - # shadowed). + # shadowed), unless you want to define specific methods / accessors in the + # temporary model in `/models/encrypt_columns/your_model.rb`. # - # To avoid depending on a particular version of the model in app/, add a - # model to `lib/gitlab/background_migration/models/encrypt_columns` and use - # it in the migration that enqueues the jobs, so code can be shared. class EncryptColumns def perform(model, attributes, from, to) model = model.constantize if model.is_a?(String) @@ -36,6 +38,10 @@ module Gitlab end end + def clear_migrated_values? + true + end + private # Build a hash of { attribute => encrypted column name } @@ -72,7 +78,10 @@ module Gitlab if instance.changed? instance.save! - instance.update_columns(to_clear) + + if clear_migrated_values? + instance.update_columns(to_clear) + end end end diff --git a/lib/gitlab/background_migration/encrypt_runners_tokens.rb b/lib/gitlab/background_migration/encrypt_runners_tokens.rb new file mode 100644 index 00000000000..91e559a8765 --- /dev/null +++ b/lib/gitlab/background_migration/encrypt_runners_tokens.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # EncryptColumn migrates data from an unencrypted column - `foo`, say - to + # an encrypted column - `encrypted_foo`, say. + # + # We only create a subclass here because we want to isolate this migration + # (migrating unencrypted runner registration tokens to encrypted columns) + # from other `EncryptColumns` migration. This class name is going to be + # serialized and stored in Redis and later picked by Sidekiq, so we need to + # create a separate class name in order to isolate these migration tasks. + # + # We can solve this differently, see tech debt issue: + # + # https://gitlab.com/gitlab-org/gitlab-ce/issues/54328 + # + class EncryptRunnersTokens < EncryptColumns + def perform(model, from, to) + resource = "::Gitlab::BackgroundMigration::Models::EncryptColumns::#{model.to_s.capitalize}" + model = resource.constantize + attributes = model.encrypted_attributes.keys + + super(model, attributes, from, to) + end + + def clear_migrated_values? + false + end + end + end +end diff --git a/lib/gitlab/background_migration/models/encrypt_columns/namespace.rb b/lib/gitlab/background_migration/models/encrypt_columns/namespace.rb new file mode 100644 index 00000000000..41f18979d76 --- /dev/null +++ b/lib/gitlab/background_migration/models/encrypt_columns/namespace.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + module Models + module EncryptColumns + # This model is shared between synchronous and background migrations to + # encrypt the `runners_token` column in `namespaces` table. + # + class Namespace < ActiveRecord::Base + include ::EachBatch + + self.table_name = 'namespaces' + self.inheritance_column = :_type_disabled + + def runners_token=(value) + self.runners_token_encrypted = + ::Gitlab::CryptoHelper.aes256_gcm_encrypt(value) + end + + def self.encrypted_attributes + { runners_token: { attribute: :runners_token_encrypted } } + end + end + end + end + end +end diff --git a/lib/gitlab/background_migration/models/encrypt_columns/project.rb b/lib/gitlab/background_migration/models/encrypt_columns/project.rb new file mode 100644 index 00000000000..bfeae14584d --- /dev/null +++ b/lib/gitlab/background_migration/models/encrypt_columns/project.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + module Models + module EncryptColumns + # This model is shared between synchronous and background migrations to + # encrypt the `runners_token` column in `projects` table. + # + class Project < ActiveRecord::Base + include ::EachBatch + + self.table_name = 'projects' + self.inheritance_column = :_type_disabled + + def runners_token=(value) + self.runners_token_encrypted = + ::Gitlab::CryptoHelper.aes256_gcm_encrypt(value) + end + + def self.encrypted_attributes + { runners_token: { attribute: :runners_token_encrypted } } + end + end + end + end + end +end diff --git a/lib/gitlab/background_migration/models/encrypt_columns/runner.rb b/lib/gitlab/background_migration/models/encrypt_columns/runner.rb new file mode 100644 index 00000000000..14ddce4b147 --- /dev/null +++ b/lib/gitlab/background_migration/models/encrypt_columns/runner.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + module Models + module EncryptColumns + # This model is shared between synchronous and background migrations to + # encrypt the `token` column in `ci_runners` table. + # + class Runner < ActiveRecord::Base + include ::EachBatch + + self.table_name = 'ci_runners' + self.inheritance_column = :_type_disabled + + def token=(value) + self.token_encrypted = + ::Gitlab::CryptoHelper.aes256_gcm_encrypt(value) + end + + def self.encrypted_attributes + { token: { attribute: :token_encrypted } } + end + end + end + end + end +end diff --git a/lib/gitlab/background_migration/models/encrypt_columns/settings.rb b/lib/gitlab/background_migration/models/encrypt_columns/settings.rb new file mode 100644 index 00000000000..08ae35c0671 --- /dev/null +++ b/lib/gitlab/background_migration/models/encrypt_columns/settings.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + module Models + module EncryptColumns + # This model is shared between synchronous and background migrations to + # encrypt the `runners_token` column in `application_settings` table. + # + class Settings < ActiveRecord::Base + include ::EachBatch + include ::CacheableAttributes + + self.table_name = 'application_settings' + self.inheritance_column = :_type_disabled + + after_commit do + ::ApplicationSetting.expire + end + + def runners_registration_token=(value) + self.runners_registration_token_encrypted = + ::Gitlab::CryptoHelper.aes256_gcm_encrypt(value) + end + + def self.encrypted_attributes + { + runners_registration_token: { + attribute: :runners_registration_token_encrypted + } + } + end + end + end + end + end +end diff --git a/lib/gitlab/background_migration/models/encrypt_columns/web_hook.rb b/lib/gitlab/background_migration/models/encrypt_columns/web_hook.rb index bb76eb8ed48..34e72fd9f34 100644 --- a/lib/gitlab/background_migration/models/encrypt_columns/web_hook.rb +++ b/lib/gitlab/background_migration/models/encrypt_columns/web_hook.rb @@ -15,12 +15,12 @@ module Gitlab attr_encrypted :token, mode: :per_attribute_iv, algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_truncated + key: ::Settings.attr_encrypted_db_key_base_32 attr_encrypted :url, mode: :per_attribute_iv, algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_truncated + key: ::Settings.attr_encrypted_db_key_base_32 end end end diff --git a/lib/gitlab/badge/coverage/report.rb b/lib/gitlab/badge/coverage/report.rb index a7fcb6b0fca..7f7cc62c8ef 100644 --- a/lib/gitlab/badge/coverage/report.rb +++ b/lib/gitlab/badge/coverage/report.rb @@ -14,7 +14,7 @@ module Gitlab @ref = ref @job = job - @pipeline = @project.pipelines.latest_successful_for(@ref) + @pipeline = @project.ci_pipelines.latest_successful_for(@ref) end def entity diff --git a/lib/gitlab/badge/pipeline/status.rb b/lib/gitlab/badge/pipeline/status.rb index 37e61f07e5b..a403d839517 100644 --- a/lib/gitlab/badge/pipeline/status.rb +++ b/lib/gitlab/badge/pipeline/status.rb @@ -22,7 +22,7 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def status - @project.pipelines + @project.ci_pipelines .where(sha: @sha) .latest_status(@ref) || 'unknown' end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 45e550b3450..eaead41a720 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -35,7 +35,7 @@ module Gitlab def handle_errors return unless errors.any? - project.update_column(:import_error, { + project.import_state.update_column(:last_error, { message: 'The remote data could not be fully imported.', errors: errors }.to_json) diff --git a/lib/gitlab/bitbucket_server_import/importer.rb b/lib/gitlab/bitbucket_server_import/importer.rb index 15aa4739ee9..d4080536d81 100644 --- a/lib/gitlab/bitbucket_server_import/importer.rb +++ b/lib/gitlab/bitbucket_server_import/importer.rb @@ -56,7 +56,7 @@ module Gitlab def handle_errors return unless errors.any? - project.update_column(:import_error, { + project.import_state.update_column(:last_error, { message: 'The remote data could not be fully imported.', errors: errors }.to_json) diff --git a/lib/gitlab/branch_push_merge_commit_analyzer.rb b/lib/gitlab/branch_push_merge_commit_analyzer.rb new file mode 100644 index 00000000000..a8f601f2451 --- /dev/null +++ b/lib/gitlab/branch_push_merge_commit_analyzer.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module Gitlab + # Analyse a graph of commits from a push to a branch, + # for each commit, analyze that if it is the head of a merge request, + # then what should its merge_commit be, relative to the branch. + # + # A----->B----->C----->D target branch + # | ^ + # | | + # +-->E----->F--+ merged branch + # | ^ + # | | + # +->G--+ + # + # (See merge-commit-analyze-after branch in gitlab-test) + # + # Assuming + # - A is already in remote + # - B~D are all in its own branch with its own merge request, targeting the target branch + # + # When D is finally pushed to the target branch, + # what are the merge commits for all the other merge requests? + # + # We can walk backwards from the HEAD commit D, + # and find status of its parents. + # First we determine if commit belongs to the target branch (i.e. A, B, C, D), + # and then determine its merge commit. + # + # +--------+-----------------+--------------+ + # | Commit | Direct ancestor | Merge commit | + # +--------+-----------------+--------------+ + # | D | Y | D | + # +--------+-----------------+--------------+ + # | C | Y | C | + # +--------+-----------------+--------------+ + # | F | | C | + # +--------+-----------------+--------------+ + # | B | Y | B | + # +--------+-----------------+--------------+ + # | E | | C | + # +--------+-----------------+--------------+ + # | G | | C | + # +--------+-----------------+--------------+ + # + # By examining the result, it can be said that + # + # - If commit is direct ancestor of HEAD, its merge commit is itself. + # - Otherwise, the merge commit is the same as its child's merge commit. + # + class BranchPushMergeCommitAnalyzer + class CommitDecorator < SimpleDelegator + attr_accessor :merge_commit + attr_writer :direct_ancestor # boolean + + def direct_ancestor? + @direct_ancestor + end + + # @param child_commit [CommitDecorator] + # @param first_parent [Boolean] whether `self` is the first parent of `child_commit` + def set_merge_commit(child_commit:) + @merge_commit ||= direct_ancestor? ? self : child_commit.merge_commit + end + end + + # @param commits [Array] list of commits, must be ordered from the child (tip) of the graph back to the ancestors + def initialize(commits, relevant_commit_ids: nil) + @commits = commits + @id_to_commit = {} + @commits.each do |commit| + @id_to_commit[commit.id] = CommitDecorator.new(commit) + + if relevant_commit_ids + relevant_commit_ids.delete(commit.id) + break if relevant_commit_ids.empty? # Only limit the analyze up to relevant_commit_ids + end + end + + analyze + end + + def get_merge_commit(id) + get_commit(id).merge_commit.id + end + + private + + def analyze + head_commit = get_commit(@commits.first.id) + head_commit.direct_ancestor = true + head_commit.merge_commit = head_commit + + mark_all_direct_ancestors(head_commit) + + # Analyzing a commit requires its child commit be analyzed first, + # which is the case here since commits are ordered from child to parent. + @id_to_commit.each_value do |commit| + analyze_parents(commit) + end + end + + def analyze_parents(commit) + commit.parent_ids.each do |parent_commit_id| + parent_commit = get_commit(parent_commit_id) + + next unless parent_commit # parent commit may not be part of new commits + + parent_commit.set_merge_commit(child_commit: commit) + end + end + + # Mark all direct ancestors. + # If child commit is a direct ancestor, its first parent is also a direct ancestor. + # We assume direct ancestors matches the trail of the target branch over time, + # This assumption is correct most of the time, especially for gitlab managed merges, + # but there are exception cases which can't be solved (https://stackoverflow.com/a/49754723/474597) + def mark_all_direct_ancestors(commit) + loop do + commit = get_commit(commit.parent_ids.first) + + break unless commit + + commit.direct_ancestor = true + end + end + + def get_commit(id) + @id_to_commit[id] + end + end +end diff --git a/lib/gitlab/checks/base_checker.rb b/lib/gitlab/checks/base_checker.rb new file mode 100644 index 00000000000..f8cda0382fe --- /dev/null +++ b/lib/gitlab/checks/base_checker.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Gitlab + module Checks + class BaseChecker + include Gitlab::Utils::StrongMemoize + + attr_reader :change_access + delegate(*ChangeAccess::ATTRIBUTES, to: :change_access) + + def initialize(change_access) + @change_access = change_access + end + + def validate! + raise NotImplementedError + end + + private + + def deletion? + Gitlab::Git.blank_ref?(newrev) + end + + def update? + !Gitlab::Git.blank_ref?(oldrev) && !deletion? + end + + def updated_from_web? + protocol == 'web' + end + + def tag_exists? + project.repository.tag_exists?(tag_name) + end + end + end +end diff --git a/lib/gitlab/checks/branch_check.rb b/lib/gitlab/checks/branch_check.rb new file mode 100644 index 00000000000..d06b2df36f2 --- /dev/null +++ b/lib/gitlab/checks/branch_check.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Gitlab + module Checks + class BranchCheck < BaseChecker + ERROR_MESSAGES = { + delete_default_branch: 'The default branch of a project cannot be deleted.', + force_push_protected_branch: 'You are not allowed to force push code to a protected branch on this project.', + non_master_delete_protected_branch: 'You are not allowed to delete protected branches from this project. Only a project maintainer or owner can delete a protected branch.', + non_web_delete_protected_branch: 'You can only delete protected branches using the web interface.', + merge_protected_branch: 'You are not allowed to merge code into protected branches on this project.', + push_protected_branch: 'You are not allowed to push code to protected branches on this project.' + }.freeze + + LOG_MESSAGES = { + delete_default_branch_check: "Checking if default branch is being deleted...", + protected_branch_checks: "Checking if you are force pushing to a protected branch...", + protected_branch_push_checks: "Checking if you are allowed to push to the protected branch...", + protected_branch_deletion_checks: "Checking if you are allowed to delete the protected branch..." + }.freeze + + def validate! + return unless branch_name + + logger.log_timed(LOG_MESSAGES[:delete_default_branch_check]) do + if deletion? && branch_name == project.default_branch + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_default_branch] + end + end + + protected_branch_checks + end + + private + + def protected_branch_checks + logger.log_timed(LOG_MESSAGES[:protected_branch_checks]) do + return unless ProtectedBranch.protected?(project, branch_name) # rubocop:disable Cop/AvoidReturnFromBlocks + + if forced_push? + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:force_push_protected_branch] + end + end + + if deletion? + protected_branch_deletion_checks + else + protected_branch_push_checks + end + end + + def protected_branch_deletion_checks + logger.log_timed(LOG_MESSAGES[:protected_branch_deletion_checks]) do + unless user_access.can_delete_branch?(branch_name) + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_master_delete_protected_branch] + end + + unless updated_from_web? + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_web_delete_protected_branch] + end + end + end + + def protected_branch_push_checks + logger.log_timed(LOG_MESSAGES[:protected_branch_push_checks]) do + if matching_merge_request? + unless user_access.can_merge_to_branch?(branch_name) || user_access.can_push_to_branch?(branch_name) + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:merge_protected_branch] + end + else + unless user_access.can_push_to_branch?(branch_name) + raise GitAccess::UnauthorizedError, push_to_protected_branch_rejected_message + end + end + end + end + + def push_to_protected_branch_rejected_message + if project.empty_repo? + empty_project_push_message + else + ERROR_MESSAGES[:push_protected_branch] + end + end + + def empty_project_push_message + <<~MESSAGE + + A default branch (e.g. master) does not yet exist for #{project.full_path} + Ask a project Owner or Maintainer to create a default branch: + + #{project_members_url} + + MESSAGE + end + + def project_members_url + Gitlab::Routing.url_helpers.project_project_members_url(project) + end + + def matching_merge_request? + Checks::MatchingMergeRequest.new(newrev, branch_name, project).match? + end + + def forced_push? + Gitlab::Checks::ForcePush.force_push?(project, oldrev, newrev) + end + end + end +end diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index 074afe9c412..7778d3068cc 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -3,35 +3,11 @@ module Gitlab module Checks class ChangeAccess - ERROR_MESSAGES = { - push_code: 'You are not allowed to push code to this project.', - delete_default_branch: 'The default branch of a project cannot be deleted.', - force_push_protected_branch: 'You are not allowed to force push code to a protected branch on this project.', - non_master_delete_protected_branch: 'You are not allowed to delete protected branches from this project. Only a project maintainer or owner can delete a protected branch.', - non_web_delete_protected_branch: 'You can only delete protected branches using the web interface.', - merge_protected_branch: 'You are not allowed to merge code into protected branches on this project.', - push_protected_branch: 'You are not allowed to push code to protected branches on this project.', - change_existing_tags: 'You are not allowed to change existing tags on this project.', - update_protected_tag: 'Protected tags cannot be updated.', - delete_protected_tag: 'Protected tags cannot be deleted.', - create_protected_tag: 'You are not allowed to create this tag as it is protected.', - lfs_objects_missing: 'LFS objects are missing. Ensure LFS is properly set up or try a manual "git lfs push --all".' - }.freeze + ATTRIBUTES = %i[user_access project skip_authorization + skip_lfs_integrity_check protocol oldrev newrev ref + branch_name tag_name logger commits].freeze - LOG_MESSAGES = { - push_checks: "Checking if you are allowed to push...", - delete_default_branch_check: "Checking if default branch is being deleted...", - protected_branch_checks: "Checking if you are force pushing to a protected branch...", - protected_branch_push_checks: "Checking if you are allowed to push to the protected branch...", - protected_branch_deletion_checks: "Checking if you are allowed to delete the protected branch...", - tag_checks: "Checking if you are allowed to change existing tags...", - protected_tag_checks: "Checking if you are creating, updating or deleting a protected tag...", - lfs_objects_exist_check: "Scanning repository for blobs stored in LFS and verifying their files have been uploaded to GitLab...", - commits_check_file_paths_validation: "Validating commits' file paths...", - commits_check: "Validating commit contents..." - }.freeze - - attr_reader :user_access, :project, :skip_authorization, :skip_lfs_integrity_check, :protocol, :oldrev, :newrev, :ref, :branch_name, :tag_name, :logger + attr_reader(*ATTRIBUTES) def initialize( change, user_access:, project:, skip_authorization: false, @@ -50,206 +26,32 @@ module Gitlab @logger.append_message("Running checks for ref: #{@branch_name || @tag_name}") end - def exec(skip_commits_check: false) + def exec return true if skip_authorization - push_checks - branch_checks - tag_checks - lfs_objects_exist_check unless skip_lfs_integrity_check - commits_check unless skip_commits_check + ref_level_checks + # Check of commits should happen as the last step + # given they're expensive in terms of performance + commits_check true end - protected - - def push_checks - logger.log_timed(LOG_MESSAGES[__method__]) do - unless can_push? - raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_code] - end - end - end - - def branch_checks - return unless branch_name - - logger.log_timed(LOG_MESSAGES[:delete_default_branch_check]) do - if deletion? && branch_name == project.default_branch - raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_default_branch] - end - end - - protected_branch_checks - end - - def protected_branch_checks - logger.log_timed(LOG_MESSAGES[__method__]) do - return unless ProtectedBranch.protected?(project, branch_name) # rubocop:disable Cop/AvoidReturnFromBlocks - - if forced_push? - raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:force_push_protected_branch] - end - end - - if deletion? - protected_branch_deletion_checks - else - protected_branch_push_checks - end - end - - def protected_branch_deletion_checks - logger.log_timed(LOG_MESSAGES[__method__]) do - unless user_access.can_delete_branch?(branch_name) - raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_master_delete_protected_branch] - end - - unless updated_from_web? - raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_web_delete_protected_branch] - end - end - end - - def protected_branch_push_checks - logger.log_timed(LOG_MESSAGES[__method__]) do - if matching_merge_request? - unless user_access.can_merge_to_branch?(branch_name) || user_access.can_push_to_branch?(branch_name) - raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:merge_protected_branch] - end - else - unless user_access.can_push_to_branch?(branch_name) - raise GitAccess::UnauthorizedError, push_to_protected_branch_rejected_message - end - end - end - end - - def tag_checks - return unless tag_name - - logger.log_timed(LOG_MESSAGES[__method__]) do - if tag_exists? && user_access.cannot_do_action?(:admin_project) - raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:change_existing_tags] - end - end - - protected_tag_checks + def commits + @commits ||= project.repository.new_commits(newrev) end - def protected_tag_checks - logger.log_timed(LOG_MESSAGES[__method__]) do - return unless ProtectedTag.protected?(project, tag_name) # rubocop:disable Cop/AvoidReturnFromBlocks - - raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:update_protected_tag]) if update? - raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_protected_tag]) if deletion? + protected - unless user_access.can_create_tag?(tag_name) - raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:create_protected_tag] - end - end + def ref_level_checks + Gitlab::Checks::PushCheck.new(self).validate! + Gitlab::Checks::BranchCheck.new(self).validate! + Gitlab::Checks::TagCheck.new(self).validate! + Gitlab::Checks::LfsCheck.new(self).validate! end def commits_check - return if deletion? || newrev.nil? - return unless should_run_commit_validations? - - logger.log_timed(LOG_MESSAGES[__method__]) do - # n+1: https://gitlab.com/gitlab-org/gitlab-ee/issues/3593 - ::Gitlab::GitalyClient.allow_n_plus_1_calls do - commits.each do |commit| - logger.check_timeout_reached - - commit_check.validate(commit, validations_for_commit(commit)) - end - end - end - - logger.log_timed(LOG_MESSAGES[:commits_check_file_paths_validation]) do - commit_check.validate_file_paths - end - end - - # Method overwritten in EE to inject custom validations - def validations_for_commit(_) - [] - end - - private - - def push_to_protected_branch_rejected_message - if project.empty_repo? - empty_project_push_message - else - ERROR_MESSAGES[:push_protected_branch] - end - end - - def empty_project_push_message - <<~MESSAGE - - A default branch (e.g. master) does not yet exist for #{project.full_path} - Ask a project Owner or Maintainer to create a default branch: - - #{project_members_url} - - MESSAGE - end - - def project_members_url - Gitlab::Routing.url_helpers.project_project_members_url(project) - end - - def should_run_commit_validations? - commit_check.validate_lfs_file_locks? - end - - def updated_from_web? - protocol == 'web' - end - - def tag_exists? - project.repository.tag_exists?(tag_name) - end - - def forced_push? - Gitlab::Checks::ForcePush.force_push?(project, oldrev, newrev) - end - - def update? - !Gitlab::Git.blank_ref?(oldrev) && !deletion? - end - - def deletion? - Gitlab::Git.blank_ref?(newrev) - end - - def matching_merge_request? - Checks::MatchingMergeRequest.new(newrev, branch_name, project).match? - end - - def lfs_objects_exist_check - logger.log_timed(LOG_MESSAGES[__method__]) do - lfs_check = Checks::LfsIntegrity.new(project, newrev, logger.time_left) - - if lfs_check.objects_missing? - raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:lfs_objects_missing] - end - end - end - - def commit_check - @commit_check ||= Gitlab::Checks::CommitCheck.new(project, user_access.user, newrev, oldrev) - end - - def commits - @commits ||= project.repository.new_commits(newrev) - end - - def can_push? - user_access.can_do_action?(:push_code) || - user_access.can_push_to_branch?(branch_name) + Gitlab::Checks::DiffCheck.new(self).validate! end end end diff --git a/lib/gitlab/checks/commit_check.rb b/lib/gitlab/checks/commit_check.rb deleted file mode 100644 index 58267b6752f..00000000000 --- a/lib/gitlab/checks/commit_check.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Checks - class CommitCheck - include Gitlab::Utils::StrongMemoize - - attr_reader :project, :user, :newrev, :oldrev - - def initialize(project, user, newrev, oldrev) - @project = project - @user = user - @newrev = newrev - @oldrev = oldrev - @file_paths = [] - end - - def validate(commit, validations) - return if validations.empty? && path_validations.empty? - - commit.raw_deltas.each do |diff| - @file_paths << (diff.new_path || diff.old_path) - - validations.each do |validation| - if error = validation.call(diff) - raise ::Gitlab::GitAccess::UnauthorizedError, error - end - end - end - end - - def validate_file_paths - path_validations.each do |validation| - if error = validation.call(@file_paths) - raise ::Gitlab::GitAccess::UnauthorizedError, error - end - end - end - - def validate_lfs_file_locks? - strong_memoize(:validate_lfs_file_locks) do - project.lfs_enabled? && newrev && oldrev && project.any_lfs_file_locks? - end - end - - private - - # rubocop: disable CodeReuse/ActiveRecord - def lfs_file_locks_validation - lambda do |paths| - lfs_lock = project.lfs_file_locks.where(path: paths).where.not(user_id: user.id).first - - if lfs_lock - return "The path '#{lfs_lock.path}' is locked in Git LFS by #{lfs_lock.user.name}" - end - end - end - # rubocop: enable CodeReuse/ActiveRecord - - def path_validations - validate_lfs_file_locks? ? [lfs_file_locks_validation] : [] - end - end - end -end diff --git a/lib/gitlab/checks/diff_check.rb b/lib/gitlab/checks/diff_check.rb new file mode 100644 index 00000000000..49d361fcef7 --- /dev/null +++ b/lib/gitlab/checks/diff_check.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Gitlab + module Checks + class DiffCheck < BaseChecker + include Gitlab::Utils::StrongMemoize + + LOG_MESSAGES = { + validate_file_paths: "Validating diffs' file paths...", + diff_content_check: "Validating diff contents..." + }.freeze + + def validate! + return unless should_run_diff_validations? + return if commits.empty? + return unless uses_raw_delta_validations? + + file_paths = [] + process_raw_deltas do |diff| + file_paths << (diff.new_path || diff.old_path) + + validate_diff(diff) + end + + validate_file_paths(file_paths) + end + + private + + def should_run_diff_validations? + newrev && oldrev && !deletion? && validate_lfs_file_locks? + end + + def validate_lfs_file_locks? + strong_memoize(:validate_lfs_file_locks) do + project.lfs_enabled? && project.any_lfs_file_locks? + end + end + + def uses_raw_delta_validations? + validations_for_diff.present? || path_validations.present? + end + + def validate_diff(diff) + validations_for_diff.each do |validation| + if error = validation.call(diff) + raise ::Gitlab::GitAccess::UnauthorizedError, error + end + end + end + + # Method overwritten in EE to inject custom validations + def validations_for_diff + [] + end + + def path_validations + validate_lfs_file_locks? ? [lfs_file_locks_validation] : [] + end + + def process_raw_deltas + logger.log_timed(LOG_MESSAGES[:diff_content_check]) do + # n+1: https://gitlab.com/gitlab-org/gitlab-ee/issues/3593 + ::Gitlab::GitalyClient.allow_n_plus_1_calls do + commits.each do |commit| + logger.check_timeout_reached + + commit.raw_deltas.each do |diff| + yield(diff) + end + end + end + end + end + + def validate_file_paths(file_paths) + logger.log_timed(LOG_MESSAGES[__method__]) do + path_validations.each do |validation| + if error = validation.call(file_paths) + raise ::Gitlab::GitAccess::UnauthorizedError, error + end + end + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def lfs_file_locks_validation + lambda do |paths| + lfs_lock = project.lfs_file_locks.where(path: paths).where.not(user_id: user_access.user.id).take + + if lfs_lock + return "The path '#{lfs_lock.path}' is locked in Git LFS by #{lfs_lock.user.name}" + end + end + end + # rubocop: enable CodeReuse/ActiveRecord + end + end +end diff --git a/lib/gitlab/checks/lfs_check.rb b/lib/gitlab/checks/lfs_check.rb new file mode 100644 index 00000000000..e42684e679a --- /dev/null +++ b/lib/gitlab/checks/lfs_check.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Gitlab + module Checks + class LfsCheck < BaseChecker + LOG_MESSAGE = "Scanning repository for blobs stored in LFS and verifying their files have been uploaded to GitLab...".freeze + ERROR_MESSAGE = 'LFS objects are missing. Ensure LFS is properly set up or try a manual "git lfs push --all".'.freeze + + def validate! + return if skip_lfs_integrity_check + + logger.log_timed(LOG_MESSAGE) do + lfs_check = Checks::LfsIntegrity.new(project, newrev, logger.time_left) + + if lfs_check.objects_missing? + raise GitAccess::UnauthorizedError, ERROR_MESSAGE + end + end + end + end + end +end diff --git a/lib/gitlab/checks/push_check.rb b/lib/gitlab/checks/push_check.rb new file mode 100644 index 00000000000..f3a52f09868 --- /dev/null +++ b/lib/gitlab/checks/push_check.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Gitlab + module Checks + class PushCheck < BaseChecker + def validate! + logger.log_timed("Checking if you are allowed to push...") do + unless can_push? + raise GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.' + end + end + end + + private + + def can_push? + user_access.can_do_action?(:push_code) || + user_access.can_push_to_branch?(branch_name) + end + end + end +end diff --git a/lib/gitlab/checks/tag_check.rb b/lib/gitlab/checks/tag_check.rb new file mode 100644 index 00000000000..2a75c8059bd --- /dev/null +++ b/lib/gitlab/checks/tag_check.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Gitlab + module Checks + class TagCheck < BaseChecker + ERROR_MESSAGES = { + change_existing_tags: 'You are not allowed to change existing tags on this project.', + update_protected_tag: 'Protected tags cannot be updated.', + delete_protected_tag: 'Protected tags cannot be deleted.', + create_protected_tag: 'You are not allowed to create this tag as it is protected.' + }.freeze + + LOG_MESSAGES = { + tag_checks: "Checking if you are allowed to change existing tags...", + protected_tag_checks: "Checking if you are creating, updating or deleting a protected tag..." + }.freeze + + def validate! + return unless tag_name + + logger.log_timed(LOG_MESSAGES[:tag_checks]) do + if tag_exists? && user_access.cannot_do_action?(:admin_project) + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:change_existing_tags] + end + end + + protected_tag_checks + end + + private + + def protected_tag_checks + logger.log_timed(LOG_MESSAGES[__method__]) do + return unless ProtectedTag.protected?(project, tag_name) # rubocop:disable Cop/AvoidReturnFromBlocks + + raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:update_protected_tag]) if update? + raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_protected_tag]) if deletion? + + unless user_access.can_create_tag?(tag_name) + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:create_protected_tag] + end + end + end + end + end +end diff --git a/lib/gitlab/ci/charts.rb b/lib/gitlab/ci/charts.rb index a4f01468e8e..7cabaadb122 100644 --- a/lib/gitlab/ci/charts.rb +++ b/lib/gitlab/ci/charts.rb @@ -54,7 +54,7 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def collect - query = project.pipelines + query = project.all_pipelines .where("? > #{::Ci::Pipeline.table_name}.created_at AND #{::Ci::Pipeline.table_name}.created_at > ?", @to, @from) # rubocop:disable GitlabSecurity/SqlInjection totals_count = grouped_count(query) @@ -115,7 +115,7 @@ module Gitlab class PipelineTime < Chart def collect - commits = project.pipelines.last(30) + commits = project.all_pipelines.last(30) commits.each do |commit| @labels << commit.short_sha diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index 2fb3c4582e7..6333799a491 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -15,7 +15,7 @@ module Gitlab @global = Entry::Global.new(@config) @global.compose! - rescue Loader::FormatError, + rescue Gitlab::Config::Loader::FormatError, Extendable::ExtensionError, External::Processor::IncludeError => e raise Config::ConfigError, e.message @@ -71,7 +71,7 @@ module Gitlab private def build_config(config, opts = {}) - initial_config = Loader.new(config).load! + initial_config = Gitlab::Config::Loader::Yaml.new(config).load! project = opts.fetch(:project, nil) if project diff --git a/lib/gitlab/ci/config/entry/artifacts.rb b/lib/gitlab/ci/config/entry/artifacts.rb index ef5f25b42c0..41613369ca2 100644 --- a/lib/gitlab/ci/config/entry/artifacts.rb +++ b/lib/gitlab/ci/config/entry/artifacts.rb @@ -7,10 +7,10 @@ module Gitlab ## # Entry that represents a configuration of job artifacts. # - class Artifacts < Node - include Configurable - include Validatable - include Attributable + class Artifacts < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable ALLOWED_KEYS = %i[name untracked paths reports when expire_in].freeze diff --git a/lib/gitlab/ci/config/entry/attributable.rb b/lib/gitlab/ci/config/entry/attributable.rb deleted file mode 100644 index 3c2e1df9b83..00000000000 --- a/lib/gitlab/ci/config/entry/attributable.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - module Attributable - extend ActiveSupport::Concern - - class_methods do - def attributes(*attributes) - attributes.flatten.each do |attribute| - if method_defined?(attribute) - raise ArgumentError, 'Method already defined!' - end - - define_method(attribute) do - return unless config.is_a?(Hash) - - config[attribute] - end - end - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/boolean.rb b/lib/gitlab/ci/config/entry/boolean.rb deleted file mode 100644 index b9639c83075..00000000000 --- a/lib/gitlab/ci/config/entry/boolean.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - ## - # Entry that represents a boolean value. - # - class Boolean < Node - include Validatable - - validations do - validates :config, boolean: true - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/cache.rb b/lib/gitlab/ci/config/entry/cache.rb index 0a25057f482..7b94af24c09 100644 --- a/lib/gitlab/ci/config/entry/cache.rb +++ b/lib/gitlab/ci/config/entry/cache.rb @@ -7,9 +7,9 @@ module Gitlab ## # Entry that represents a cache configuration # - class Cache < Node - include Configurable - include Attributable + class Cache < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Attributable ALLOWED_KEYS = %i[key untracked paths policy].freeze DEFAULT_POLICY = 'pull-push'.freeze @@ -22,7 +22,7 @@ module Gitlab entry :key, Entry::Key, description: 'Cache key used to define a cache affinity.' - entry :untracked, Entry::Boolean, + entry :untracked, ::Gitlab::Config::Entry::Boolean, description: 'Cache all untracked files.' entry :paths, Entry::Paths, diff --git a/lib/gitlab/ci/config/entry/commands.rb b/lib/gitlab/ci/config/entry/commands.rb index d9658291ebe..02e368c1813 100644 --- a/lib/gitlab/ci/config/entry/commands.rb +++ b/lib/gitlab/ci/config/entry/commands.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents a job script. # - class Commands < Node - include Validatable + class Commands < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, array_of_strings_or_string: true diff --git a/lib/gitlab/ci/config/entry/configurable.rb b/lib/gitlab/ci/config/entry/configurable.rb deleted file mode 100644 index 4aabf0cfa31..00000000000 --- a/lib/gitlab/ci/config/entry/configurable.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - ## - # This mixin is responsible for adding DSL, which purpose is to - # simplifly process of adding child nodes. - # - # This can be used only if parent node is a configuration entry that - # holds a hash as a configuration value, for example: - # - # job: - # script: ... - # artifacts: ... - # - module Configurable - extend ActiveSupport::Concern - - included do - include Validatable - - validations do - validates :config, type: Hash - end - end - - # rubocop: disable CodeReuse/ActiveRecord - def compose!(deps = nil) - return unless valid? - - self.class.nodes.each do |key, factory| - factory - .value(config[key]) - .with(key: key, parent: self) - - entries[key] = factory.create! - end - - yield if block_given? - - entries.each_value do |entry| - entry.compose!(deps) - end - end - # rubocop: enable CodeReuse/ActiveRecord - - class_methods do - def nodes - Hash[(@nodes || {}).map { |key, factory| [key, factory.dup] }] - end - - private - - # rubocop: disable CodeReuse/ActiveRecord - def entry(key, entry, metadata) - factory = Entry::Factory.new(entry) - .with(description: metadata[:description]) - - (@nodes ||= {}).merge!(key.to_sym => factory) - end - # rubocop: enable CodeReuse/ActiveRecord - - def helpers(*nodes) - nodes.each do |symbol| - define_method("#{symbol}_defined?") do - entries[symbol]&.specified? - end - - define_method("#{symbol}_value") do - return unless entries[symbol] && entries[symbol].valid? - - entries[symbol].value - end - end - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/coverage.rb b/lib/gitlab/ci/config/entry/coverage.rb index 690409ccf77..89545158bed 100644 --- a/lib/gitlab/ci/config/entry/coverage.rb +++ b/lib/gitlab/ci/config/entry/coverage.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents Coverage settings. # - class Coverage < Node - include Validatable + class Coverage < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, regexp: true diff --git a/lib/gitlab/ci/config/entry/environment.rb b/lib/gitlab/ci/config/entry/environment.rb index 07e9e1d3f67..69a3a1aedef 100644 --- a/lib/gitlab/ci/config/entry/environment.rb +++ b/lib/gitlab/ci/config/entry/environment.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents an environment. # - class Environment < Node - include Validatable + class Environment < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable ALLOWED_KEYS = %i[name url action on_stop].freeze diff --git a/lib/gitlab/ci/config/entry/except_policy.rb b/lib/gitlab/ci/config/entry/except_policy.rb new file mode 100644 index 00000000000..46ded35325d --- /dev/null +++ b/lib/gitlab/ci/config/entry/except_policy.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents an only/except trigger policy for the job. + # + class ExceptPolicy < Policy + def self.default + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/factory.rb b/lib/gitlab/ci/config/entry/factory.rb deleted file mode 100644 index 85c9c3511a4..00000000000 --- a/lib/gitlab/ci/config/entry/factory.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - ## - # Factory class responsible for fabricating entry objects. - # - class Factory - InvalidFactory = Class.new(StandardError) - - def initialize(entry) - @entry = entry - @metadata = {} - @attributes = {} - end - - def value(value) - @value = value - self - end - - def metadata(metadata) - @metadata.merge!(metadata) - self - end - - def with(attributes) - @attributes.merge!(attributes) - self - end - - def create! - raise InvalidFactory unless defined?(@value) - - ## - # We assume that unspecified entry is undefined. - # See issue #18775. - # - if @value.nil? - Entry::Unspecified.new( - fabricate_unspecified - ) - else - fabricate(@entry, @value) - end - end - - private - - def fabricate_unspecified - ## - # If entry has a default value we fabricate concrete node - # with default value. - # - if @entry.default.nil? - fabricate(Entry::Undefined) - else - fabricate(@entry, @entry.default) - end - end - - def fabricate(entry, value = nil) - entry.new(value, @metadata).tap do |node| - node.key = @attributes[:key] - node.parent = @attributes[:parent] - node.description = @attributes[:description] - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/global.rb b/lib/gitlab/ci/config/entry/global.rb index eba203d9d06..09ecb5fdb99 100644 --- a/lib/gitlab/ci/config/entry/global.rb +++ b/lib/gitlab/ci/config/entry/global.rb @@ -8,8 +8,8 @@ module Gitlab # This class represents a global entry - root Entry for entire # GitLab CI Configuration file. # - class Global < Node - include Configurable + class Global < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable entry :before_script, Entry::Script, description: 'Script that will be executed before each job.' @@ -49,7 +49,7 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def compose_jobs! - factory = Entry::Factory.new(Entry::Jobs) + factory = ::Gitlab::Config::Entry::Factory.new(Entry::Jobs) .value(@config.except(*self.class.nodes.keys)) .with(key: :jobs, parent: self, description: 'Jobs definition for this pipeline') diff --git a/lib/gitlab/ci/config/entry/hidden.rb b/lib/gitlab/ci/config/entry/hidden.rb index dc0ede2a25f..76e5d05639f 100644 --- a/lib/gitlab/ci/config/entry/hidden.rb +++ b/lib/gitlab/ci/config/entry/hidden.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents a hidden CI/CD key. # - class Hidden < Node - include Validatable + class Hidden < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, presence: true diff --git a/lib/gitlab/ci/config/entry/image.rb b/lib/gitlab/ci/config/entry/image.rb index fc453b72fa5..a13a0625e90 100644 --- a/lib/gitlab/ci/config/entry/image.rb +++ b/lib/gitlab/ci/config/entry/image.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents a Docker image. # - class Image < Node - include Validatable + class Image < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable ALLOWED_KEYS = %i[name entrypoint].freeze diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index c8cb3248fa7..085be5da08d 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -7,9 +7,9 @@ module Gitlab ## # Entry that represents a concrete CI/CD job. # - class Job < Node - include Configurable - include Attributable + class Job < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Attributable ALLOWED_KEYS = %i[tags script only except type image services allow_failure type stage when start_in artifacts cache @@ -65,10 +65,10 @@ module Gitlab entry :services, Entry::Services, description: 'Services that will be used to execute this job.' - entry :only, Entry::Policy, + entry :only, Entry::OnlyPolicy, description: 'Refs policy this job will be executed for.' - entry :except, Entry::Policy, + entry :except, Entry::ExceptPolicy, description: 'Refs policy this job will be executed for.' entry :variables, Entry::Variables, diff --git a/lib/gitlab/ci/config/entry/jobs.rb b/lib/gitlab/ci/config/entry/jobs.rb index 1535b108000..82b72e40404 100644 --- a/lib/gitlab/ci/config/entry/jobs.rb +++ b/lib/gitlab/ci/config/entry/jobs.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents a set of jobs. # - class Jobs < Node - include Validatable + class Jobs < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, type: Hash @@ -34,7 +34,7 @@ module Gitlab @config.each do |name, config| node = hidden?(name) ? Entry::Hidden : Entry::Job - factory = Entry::Factory.new(node) + factory = ::Gitlab::Config::Entry::Factory.new(node) .value(config || {}) .metadata(name: name) .with(key: name, parent: self, diff --git a/lib/gitlab/ci/config/entry/key.rb b/lib/gitlab/ci/config/entry/key.rb index 963b200c7bb..0c10967e629 100644 --- a/lib/gitlab/ci/config/entry/key.rb +++ b/lib/gitlab/ci/config/entry/key.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents a key. # - class Key < Node - include Validatable + class Key < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, key: true diff --git a/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb b/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb deleted file mode 100644 index 4043629dea9..00000000000 --- a/lib/gitlab/ci/config/entry/legacy_validation_helpers.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - module LegacyValidationHelpers - private - - def validate_duration(value) - value.is_a?(String) && ChronicDuration.parse(value) - rescue ChronicDuration::DurationParseError - false - end - - def validate_duration_limit(value, limit) - return false unless value.is_a?(String) - - ChronicDuration.parse(value).second.from_now < - ChronicDuration.parse(limit).second.from_now - rescue ChronicDuration::DurationParseError - false - end - - def validate_array_of_strings(values) - values.is_a?(Array) && values.all? { |value| validate_string(value) } - end - - def validate_array_of_strings_or_regexps(values) - values.is_a?(Array) && values.all? { |value| validate_string_or_regexp(value) } - end - - def validate_variables(variables) - variables.is_a?(Hash) && - variables.flatten.all? do |value| - validate_string(value) || validate_integer(value) - end - end - - def validate_integer(value) - value.is_a?(Integer) - end - - def validate_string(value) - value.is_a?(String) || value.is_a?(Symbol) - end - - def validate_regexp(value) - !value.nil? && Regexp.new(value.to_s) && true - rescue RegexpError, TypeError - false - end - - def validate_string_or_regexp(value) - return true if value.is_a?(Symbol) - return false unless value.is_a?(String) - - if value.first == '/' && value.last == '/' - validate_regexp(value[1...-1]) - else - true - end - end - - def validate_boolean(value) - value.in?([true, false]) - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/node.rb b/lib/gitlab/ci/config/entry/node.rb deleted file mode 100644 index 347089722e4..00000000000 --- a/lib/gitlab/ci/config/entry/node.rb +++ /dev/null @@ -1,103 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - ## - # Base abstract class for each configuration entry node. - # - class Node - InvalidError = Class.new(StandardError) - - attr_reader :config, :metadata - attr_accessor :key, :parent, :description - - def initialize(config, **metadata) - @config = config - @metadata = metadata - @entries = {} - - self.class.aspects.to_a.each do |aspect| - instance_exec(&aspect) - end - end - - def [](key) - @entries[key] || Entry::Undefined.new - end - - def compose!(deps = nil) - return unless valid? - - yield if block_given? - end - - def leaf? - @entries.none? - end - - def descendants - @entries.values - end - - def ancestors - @parent ? @parent.ancestors + [@parent] : [] - end - - def valid? - errors.none? - end - - def errors - [] - end - - def value - if leaf? - @config - else - meaningful = @entries.select do |_key, value| - value.specified? && value.relevant? - end - - Hash[meaningful.map { |key, entry| [key, entry.value] }] - end - end - - def specified? - true - end - - def relevant? - true - end - - def location - name = @key.presence || self.class.name.to_s.demodulize - .underscore.humanize.downcase - - ancestors.map(&:key).append(name).compact.join(':') - end - - def inspect - val = leaf? ? config : descendants - unspecified = specified? ? '' : '(unspecified) ' - "#<#{self.class.name} #{unspecified}{#{key}: #{val.inspect}}>" - end - - def self.default - end - - def self.aspects - @aspects ||= [] - end - - private - - attr_reader :entries - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/only_policy.rb b/lib/gitlab/ci/config/entry/only_policy.rb new file mode 100644 index 00000000000..9a581b8e97e --- /dev/null +++ b/lib/gitlab/ci/config/entry/only_policy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents an only/except trigger policy for the job. + # + class OnlyPolicy < Policy + def self.default + { refs: %w[branches tags] } + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/paths.rb b/lib/gitlab/ci/config/entry/paths.rb index 9580b5e2e7f..d6f287c6552 100644 --- a/lib/gitlab/ci/config/entry/paths.rb +++ b/lib/gitlab/ci/config/entry/paths.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents an array of paths. # - class Paths < Node - include Validatable + class Paths < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, array_of_strings: true diff --git a/lib/gitlab/ci/config/entry/policy.rb b/lib/gitlab/ci/config/entry/policy.rb index 0535d7c1a1a..81e74a639fc 100644 --- a/lib/gitlab/ci/config/entry/policy.rb +++ b/lib/gitlab/ci/config/entry/policy.rb @@ -5,14 +5,11 @@ module Gitlab class Config module Entry ## - # Entry that represents an only/except trigger policy for the job. + # Base class for OnlyPolicy and ExceptPolicy # - class Policy < Simplifiable - strategy :RefsPolicy, if: -> (config) { config.is_a?(Array) } - strategy :ComplexPolicy, if: -> (config) { config.is_a?(Hash) } - - class RefsPolicy < Entry::Node - include Entry::Validatable + class Policy < ::Gitlab::Config::Entry::Simplifiable + class RefsPolicy < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, array_of_strings_or_regexps: true @@ -23,9 +20,9 @@ module Gitlab end end - class ComplexPolicy < Entry::Node - include Entry::Validatable - include Entry::Attributable + class ComplexPolicy < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable ALLOWED_KEYS = %i[refs kubernetes variables changes].freeze attributes :refs, :kubernetes, :variables, :changes @@ -58,7 +55,7 @@ module Gitlab end end - class UnknownStrategy < Entry::Node + class UnknownStrategy < ::Gitlab::Config::Entry::Node def errors ["#{location} has to be either an array of conditions or a hash"] end @@ -66,6 +63,16 @@ module Gitlab def self.default end + + ## + # Class-level execution won't be inherited by subclasses by default. + # Therefore, we need to explicitly execute that for OnlyPolicy and ExceptPolicy + def self.inherited(klass) + super + + klass.strategy :RefsPolicy, if: -> (config) { config.is_a?(Array) } + klass.strategy :ComplexPolicy, if: -> (config) { config.is_a?(Hash) } + end end end end diff --git a/lib/gitlab/ci/config/entry/reports.rb b/lib/gitlab/ci/config/entry/reports.rb index 3ac2a6fa777..a3f6cc31321 100644 --- a/lib/gitlab/ci/config/entry/reports.rb +++ b/lib/gitlab/ci/config/entry/reports.rb @@ -7,9 +7,9 @@ module Gitlab ## # Entry that represents a configuration of job artifacts. # - class Reports < Node - include Validatable - include Attributable + class Reports < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable ALLOWED_KEYS = %i[junit codequality sast dependency_scanning container_scanning dast performance license_management].freeze diff --git a/lib/gitlab/ci/config/entry/retry.rb b/lib/gitlab/ci/config/entry/retry.rb index ee82ab10f9c..eaf8b38aa3c 100644 --- a/lib/gitlab/ci/config/entry/retry.rb +++ b/lib/gitlab/ci/config/entry/retry.rb @@ -7,12 +7,12 @@ module Gitlab ## # Entry that represents a retry config for a job. # - class Retry < Simplifiable + class Retry < ::Gitlab::Config::Entry::Simplifiable strategy :SimpleRetry, if: -> (config) { config.is_a?(Integer) } strategy :FullRetry, if: -> (config) { config.is_a?(Hash) } - class SimpleRetry < Entry::Node - include Entry::Validatable + class SimpleRetry < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, numericality: { only_integer: true, @@ -31,9 +31,9 @@ module Gitlab end end - class FullRetry < Entry::Node - include Entry::Validatable - include Entry::Attributable + class FullRetry < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable ALLOWED_KEYS = %i[max when].freeze attributes :max, :when @@ -73,7 +73,7 @@ module Gitlab end end - class UnknownStrategy < Entry::Node + class UnknownStrategy < ::Gitlab::Config::Entry::Node def errors ["#{location} has to be either an integer or a hash"] end diff --git a/lib/gitlab/ci/config/entry/script.rb b/lib/gitlab/ci/config/entry/script.rb index f7d39e5cf55..9d25a82b521 100644 --- a/lib/gitlab/ci/config/entry/script.rb +++ b/lib/gitlab/ci/config/entry/script.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents a script. # - class Script < Node - include Validatable + class Script < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, array_of_strings: true diff --git a/lib/gitlab/ci/config/entry/service.rb b/lib/gitlab/ci/config/entry/service.rb index 47bf9205147..6df67083310 100644 --- a/lib/gitlab/ci/config/entry/service.rb +++ b/lib/gitlab/ci/config/entry/service.rb @@ -8,7 +8,7 @@ module Gitlab # Entry that represents a configuration of Docker service. # class Service < Image - include Validatable + include ::Gitlab::Config::Entry::Validatable ALLOWED_KEYS = %i[name entrypoint command alias].freeze diff --git a/lib/gitlab/ci/config/entry/services.rb b/lib/gitlab/ci/config/entry/services.rb index bdf7f80f382..71475f69218 100644 --- a/lib/gitlab/ci/config/entry/services.rb +++ b/lib/gitlab/ci/config/entry/services.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents a configuration of Docker services. # - class Services < Node - include Validatable + class Services < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, type: Array @@ -18,7 +18,7 @@ module Gitlab super do @entries = [] @config.each do |config| - @entries << Entry::Factory.new(Entry::Service) + @entries << ::Gitlab::Config::Entry::Factory.new(Entry::Service) .value(config || {}) .create! end diff --git a/lib/gitlab/ci/config/entry/simplifiable.rb b/lib/gitlab/ci/config/entry/simplifiable.rb deleted file mode 100644 index 9961bbfaa40..00000000000 --- a/lib/gitlab/ci/config/entry/simplifiable.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - class Simplifiable < SimpleDelegator - EntryStrategy = Struct.new(:name, :condition) - - def initialize(config, **metadata) - unless self.class.const_defined?(:UnknownStrategy) - raise ArgumentError, 'UndefinedStrategy not available!' - end - - strategy = self.class.strategies.find do |variant| - variant.condition.call(config) - end - - entry = self.class.entry_class(strategy) - - super(entry.new(config, metadata)) - end - - def self.strategy(name, **opts) - EntryStrategy.new(name, opts.fetch(:if)).tap do |strategy| - strategies.append(strategy) - end - end - - def self.strategies - @strategies ||= [] - end - - def self.entry_class(strategy) - if strategy.present? - self.const_get(strategy.name) - else - self::UnknownStrategy - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/stage.rb b/lib/gitlab/ci/config/entry/stage.rb index 65ab5953131..d6d576a3139 100644 --- a/lib/gitlab/ci/config/entry/stage.rb +++ b/lib/gitlab/ci/config/entry/stage.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents a stage for a job. # - class Stage < Node - include Validatable + class Stage < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, type: String diff --git a/lib/gitlab/ci/config/entry/stages.rb b/lib/gitlab/ci/config/entry/stages.rb index ab184246d29..2d715cbc6bb 100644 --- a/lib/gitlab/ci/config/entry/stages.rb +++ b/lib/gitlab/ci/config/entry/stages.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents a configuration for pipeline stages. # - class Stages < Node - include Validatable + class Stages < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, array_of_strings: true diff --git a/lib/gitlab/ci/config/entry/undefined.rb b/lib/gitlab/ci/config/entry/undefined.rb deleted file mode 100644 index 77dcfa88170..00000000000 --- a/lib/gitlab/ci/config/entry/undefined.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - ## - # This class represents an undefined entry. - # - class Undefined < Node - def initialize(*) - super(nil) - end - - def value - nil - end - - def valid? - true - end - - def errors - [] - end - - def specified? - false - end - - def relevant? - false - end - - def inspect - "#<#{self.class.name}>" - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/unspecified.rb b/lib/gitlab/ci/config/entry/unspecified.rb deleted file mode 100644 index bab32489d2f..00000000000 --- a/lib/gitlab/ci/config/entry/unspecified.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - ## - # This class represents an unspecified entry. - # - # It decorates original entry adding method that indicates it is - # unspecified. - # - class Unspecified < SimpleDelegator - def specified? - false - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/validatable.rb b/lib/gitlab/ci/config/entry/validatable.rb deleted file mode 100644 index 08a6593c980..00000000000 --- a/lib/gitlab/ci/config/entry/validatable.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - module Validatable - extend ActiveSupport::Concern - - def self.included(node) - node.aspects.append -> do - @validator = self.class.validator.new(self) - @validator.validate(:new) - end - end - - def errors - @validator.messages + descendants.flat_map(&:errors) # rubocop:disable Gitlab/ModuleWithInstanceVariables - end - - class_methods do - def validator - @validator ||= Class.new(Entry::Validator).tap do |validator| - if defined?(@validations) - @validations.each { |rules| validator.class_eval(&rules) } - end - end - end - - private - - def validations(&block) - (@validations ||= []).append(block) - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/validator.rb b/lib/gitlab/ci/config/entry/validator.rb deleted file mode 100644 index 33ffdd3a95d..00000000000 --- a/lib/gitlab/ci/config/entry/validator.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - class Validator < SimpleDelegator - include ActiveModel::Validations - include Entry::Validators - - def initialize(entry) - super(entry) - end - - def messages - errors.full_messages.map do |error| - "#{location} #{error}".downcase - end - end - - def self.name - 'Validator' - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/validators.rb b/lib/gitlab/ci/config/entry/validators.rb deleted file mode 100644 index a1d552fb2e5..00000000000 --- a/lib/gitlab/ci/config/entry/validators.rb +++ /dev/null @@ -1,198 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - class Config - module Entry - module Validators - class AllowedKeysValidator < ActiveModel::EachValidator - def validate_each(record, attribute, value) - unknown_keys = value.try(:keys).to_a - options[:in] - - if unknown_keys.any? - record.errors.add(attribute, "contains unknown keys: " + - unknown_keys.join(', ')) - end - end - end - - class AllowedValuesValidator < ActiveModel::EachValidator - def validate_each(record, attribute, value) - unless options[:in].include?(value.to_s) - record.errors.add(attribute, "unknown value: #{value}") - end - end - end - - class AllowedArrayValuesValidator < ActiveModel::EachValidator - def validate_each(record, attribute, value) - unkown_values = value - options[:in] - unless unkown_values.empty? - record.errors.add(attribute, "contains unknown values: " + - unkown_values.join(', ')) - end - end - end - - class ArrayOfStringsValidator < ActiveModel::EachValidator - include LegacyValidationHelpers - - def validate_each(record, attribute, value) - unless validate_array_of_strings(value) - record.errors.add(attribute, 'should be an array of strings') - end - end - end - - class BooleanValidator < ActiveModel::EachValidator - include LegacyValidationHelpers - - def validate_each(record, attribute, value) - unless validate_boolean(value) - record.errors.add(attribute, 'should be a boolean value') - end - end - end - - class DurationValidator < ActiveModel::EachValidator - include LegacyValidationHelpers - - def validate_each(record, attribute, value) - unless validate_duration(value) - record.errors.add(attribute, 'should be a duration') - end - - if options[:limit] - unless validate_duration_limit(value, options[:limit]) - record.errors.add(attribute, 'should not exceed the limit') - end - end - end - end - - class HashOrStringValidator < ActiveModel::EachValidator - def validate_each(record, attribute, value) - unless value.is_a?(Hash) || value.is_a?(String) - record.errors.add(attribute, 'should be a hash or a string') - end - end - end - - class HashOrIntegerValidator < ActiveModel::EachValidator - def validate_each(record, attribute, value) - unless value.is_a?(Hash) || value.is_a?(Integer) - record.errors.add(attribute, 'should be a hash or an integer') - end - end - end - - class KeyValidator < ActiveModel::EachValidator - include LegacyValidationHelpers - - def validate_each(record, attribute, value) - if validate_string(value) - validate_path(record, attribute, value) - else - record.errors.add(attribute, 'should be a string or symbol') - end - end - - private - - def validate_path(record, attribute, value) - path = CGI.unescape(value.to_s) - - if path.include?('/') - record.errors.add(attribute, 'cannot contain the "/" character') - elsif path == '.' || path == '..' - record.errors.add(attribute, 'cannot be "." or ".."') - end - end - end - - class RegexpValidator < ActiveModel::EachValidator - include LegacyValidationHelpers - - def validate_each(record, attribute, value) - unless validate_regexp(value) - record.errors.add(attribute, 'must be a regular expression') - end - end - - private - - def look_like_regexp?(value) - value.is_a?(String) && value.start_with?('/') && - value.end_with?('/') - end - - def validate_regexp(value) - look_like_regexp?(value) && - Regexp.new(value.to_s[1...-1]) && - true - rescue RegexpError - false - end - end - - class ArrayOfStringsOrRegexpsValidator < RegexpValidator - def validate_each(record, attribute, value) - unless validate_array_of_strings_or_regexps(value) - record.errors.add(attribute, 'should be an array of strings or regexps') - end - end - - private - - def validate_array_of_strings_or_regexps(values) - values.is_a?(Array) && values.all?(&method(:validate_string_or_regexp)) - end - - def validate_string_or_regexp(value) - return false unless value.is_a?(String) - return validate_regexp(value) if look_like_regexp?(value) - - true - end - end - - class ArrayOfStringsOrStringValidator < RegexpValidator - def validate_each(record, attribute, value) - unless validate_array_of_strings_or_string(value) - record.errors.add(attribute, 'should be an array of strings or a string') - end - end - - private - - def validate_array_of_strings_or_string(values) - validate_array_of_strings(values) || validate_string(values) - end - end - - class TypeValidator < ActiveModel::EachValidator - def validate_each(record, attribute, value) - type = options[:with] - raise unless type.is_a?(Class) - - unless value.is_a?(type) - message = options[:message] || "should be a #{type.name}" - record.errors.add(attribute, message) - end - end - end - - class VariablesValidator < ActiveModel::EachValidator - include LegacyValidationHelpers - - def validate_each(record, attribute, value) - unless validate_variables(value) - record.errors.add(attribute, 'should be a hash of key value pairs') - end - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/variables.rb b/lib/gitlab/ci/config/entry/variables.rb index 6fd3cec2f5f..89d790ebfa6 100644 --- a/lib/gitlab/ci/config/entry/variables.rb +++ b/lib/gitlab/ci/config/entry/variables.rb @@ -7,8 +7,8 @@ module Gitlab ## # Entry that represents environment variables. # - class Variables < Node - include Validatable + class Variables < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable validations do validates :config, variables: true diff --git a/lib/gitlab/ci/config/external/file/base.rb b/lib/gitlab/ci/config/external/file/base.rb index 15ca47ef60e..ee4ea9bbb1d 100644 --- a/lib/gitlab/ci/config/external/file/base.rb +++ b/lib/gitlab/ci/config/external/file/base.rb @@ -37,8 +37,8 @@ module Gitlab end def to_hash - @hash ||= Ci::Config::Loader.new(content).load! - rescue Ci::Config::Loader::FormatError + @hash ||= Gitlab::Config::Loader::Yaml.new(content).load! + rescue Gitlab::Config::Loader::FormatError nil end diff --git a/lib/gitlab/ci/pipeline/chain/build.rb b/lib/gitlab/ci/pipeline/chain/build.rb index b445a872b3d..d33d1edfe35 100644 --- a/lib/gitlab/ci/pipeline/chain/build.rb +++ b/lib/gitlab/ci/pipeline/chain/build.rb @@ -16,6 +16,7 @@ module Gitlab trigger_requests: Array(@command.trigger_request), user: @command.current_user, pipeline_schedule: @command.schedule, + merge_request: @command.merge_request, protected: @command.protected_ref?, variables_attributes: Array(@command.variables_attributes) ) diff --git a/lib/gitlab/ci/pipeline/chain/command.rb b/lib/gitlab/ci/pipeline/chain/command.rb index 05978804d92..100b9521412 100644 --- a/lib/gitlab/ci/pipeline/chain/command.rb +++ b/lib/gitlab/ci/pipeline/chain/command.rb @@ -8,7 +8,7 @@ module Gitlab Command = Struct.new( :source, :project, :current_user, :origin_ref, :checkout_sha, :after_sha, :before_sha, - :trigger_request, :schedule, + :trigger_request, :schedule, :merge_request, :ignore_skip_ci, :save_incompleted, :seeds_block, :variables_attributes ) do diff --git a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml index c90976b2040..3b2cae07c12 100644 --- a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml @@ -19,15 +19,6 @@ # * review: REVIEW_DISABLED # * stop_review: REVIEW_DISABLED # -# The sast and sast_dashboard jobs are executed to guarantee full compatibility -# with the group security dashboard and the security reports with old runners. -# If you use only runners with version 11.5 or above, you can disable the sast -# job by setting the OLD_REPORTS_DISABLED environment variable. If you use only -# runners with version below 11.5, you can disable the sast_dashboard job by -# setting the NEW_REPORTS_DISABLED environment variable. -# The sast_dashboard job will be removed in the future, when the sast job will -# use the new reports syntax. -# # In order to deploy, you must have a Kubernetes cluster configured either # via a project integration, or via group/project variables. # AUTO_DEVOPS_DOMAIN must also be set as a variable at the group or project @@ -182,29 +173,6 @@ sast: except: variables: - $SAST_DISABLED - - $OLD_REPORTS_DISABLED - -sast_dashboard: - stage: test - image: docker:stable - allow_failure: true - services: - - docker:stable-dind - script: - - setup_docker - - sast - artifacts: - reports: - sast: gl-sast-report.json - only: - refs: - - branches - variables: - - $GITLAB_FEATURES =~ /\bsast\b/ - except: - variables: - - $SAST_DISABLED - - $NEW_REPORTS_DISABLED dependency_scanning: stage: test @@ -658,6 +626,7 @@ rollout 100%: fi if [[ -n "$DB_INITIALIZE" && -z "$(helm ls -q "^$name$")" ]]; then + echo "Deploying first release with database initialization..." helm upgrade --install \ --wait \ --set service.enabled="$service_enabled" \ @@ -680,6 +649,7 @@ rollout 100%: "$name" \ chart/ + echo "Deploying second release..." helm upgrade --reuse-values \ --wait \ --set application.initializeCommand="" \ @@ -688,6 +658,7 @@ rollout 100%: "$name" \ chart/ else + echo "Deploying new release..." helm upgrade --install \ --wait \ --set service.enabled="$service_enabled" \ diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb index e6ec400e476..172926b8ab0 100644 --- a/lib/gitlab/ci/yaml_processor.rb +++ b/lib/gitlab/ci/yaml_processor.rb @@ -5,7 +5,7 @@ module Gitlab class YamlProcessor ValidationError = Class.new(StandardError) - include Gitlab::Ci::Config::Entry::LegacyValidationHelpers + include Gitlab::Config::Entry::LegacyValidationHelpers attr_reader :cache, :stages, :jobs diff --git a/lib/gitlab/config/entry/attributable.rb b/lib/gitlab/config/entry/attributable.rb new file mode 100644 index 00000000000..560fe63df0e --- /dev/null +++ b/lib/gitlab/config/entry/attributable.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + module Attributable + extend ActiveSupport::Concern + + class_methods do + def attributes(*attributes) + attributes.flatten.each do |attribute| + if method_defined?(attribute) + raise ArgumentError, 'Method already defined!' + end + + define_method(attribute) do + return unless config.is_a?(Hash) + + config[attribute] + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/config/entry/boolean.rb b/lib/gitlab/config/entry/boolean.rb new file mode 100644 index 00000000000..1e8a57356e3 --- /dev/null +++ b/lib/gitlab/config/entry/boolean.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + ## + # Entry that represents a boolean value. + # + class Boolean < Node + include Validatable + + validations do + validates :config, boolean: true + end + end + end + end +end diff --git a/lib/gitlab/config/entry/configurable.rb b/lib/gitlab/config/entry/configurable.rb new file mode 100644 index 00000000000..afdb60b2cd5 --- /dev/null +++ b/lib/gitlab/config/entry/configurable.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + ## + # This mixin is responsible for adding DSL, which purpose is to + # simplifly process of adding child nodes. + # + # This can be used only if parent node is a configuration entry that + # holds a hash as a configuration value, for example: + # + # job: + # script: ... + # artifacts: ... + # + module Configurable + extend ActiveSupport::Concern + + included do + include Validatable + + validations do + validates :config, type: Hash + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def compose!(deps = nil) + return unless valid? + + self.class.nodes.each do |key, factory| + factory + .value(config[key]) + .with(key: key, parent: self) + + entries[key] = factory.create! + end + + yield if block_given? + + entries.each_value do |entry| + entry.compose!(deps) + end + end + # rubocop: enable CodeReuse/ActiveRecord + + class_methods do + def nodes + Hash[(@nodes || {}).map { |key, factory| [key, factory.dup] }] + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def entry(key, entry, metadata) + factory = ::Gitlab::Config::Entry::Factory.new(entry) + .with(description: metadata[:description]) + + (@nodes ||= {}).merge!(key.to_sym => factory) + end + # rubocop: enable CodeReuse/ActiveRecord + + def helpers(*nodes) + nodes.each do |symbol| + define_method("#{symbol}_defined?") do + entries[symbol]&.specified? + end + + define_method("#{symbol}_value") do + return unless entries[symbol] && entries[symbol].valid? + + entries[symbol].value + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/config/entry/factory.rb b/lib/gitlab/config/entry/factory.rb new file mode 100644 index 00000000000..30d43c9f9a1 --- /dev/null +++ b/lib/gitlab/config/entry/factory.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + ## + # Factory class responsible for fabricating entry objects. + # + class Factory + InvalidFactory = Class.new(StandardError) + + def initialize(entry) + @entry = entry + @metadata = {} + @attributes = {} + end + + def value(value) + @value = value + self + end + + def metadata(metadata) + @metadata.merge!(metadata) + self + end + + def with(attributes) + @attributes.merge!(attributes) + self + end + + def create! + raise InvalidFactory unless defined?(@value) + + ## + # We assume that unspecified entry is undefined. + # See issue #18775. + # + if @value.nil? + Entry::Unspecified.new( + fabricate_unspecified + ) + else + fabricate(@entry, @value) + end + end + + private + + def fabricate_unspecified + ## + # If entry has a default value we fabricate concrete node + # with default value. + # + if @entry.default.nil? + fabricate(Entry::Undefined) + else + fabricate(@entry, @entry.default) + end + end + + def fabricate(entry, value = nil) + entry.new(value, @metadata).tap do |node| + node.key = @attributes[:key] + node.parent = @attributes[:parent] + node.description = @attributes[:description] + end + end + end + end + end +end diff --git a/lib/gitlab/config/entry/legacy_validation_helpers.rb b/lib/gitlab/config/entry/legacy_validation_helpers.rb new file mode 100644 index 00000000000..d3ab5625743 --- /dev/null +++ b/lib/gitlab/config/entry/legacy_validation_helpers.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + module LegacyValidationHelpers + private + + def validate_duration(value) + value.is_a?(String) && ChronicDuration.parse(value) + rescue ChronicDuration::DurationParseError + false + end + + def validate_duration_limit(value, limit) + return false unless value.is_a?(String) + + ChronicDuration.parse(value).second.from_now < + ChronicDuration.parse(limit).second.from_now + rescue ChronicDuration::DurationParseError + false + end + + def validate_array_of_strings(values) + values.is_a?(Array) && values.all? { |value| validate_string(value) } + end + + def validate_array_of_strings_or_regexps(values) + values.is_a?(Array) && values.all? { |value| validate_string_or_regexp(value) } + end + + def validate_variables(variables) + variables.is_a?(Hash) && + variables.flatten.all? do |value| + validate_string(value) || validate_integer(value) + end + end + + def validate_integer(value) + value.is_a?(Integer) + end + + def validate_string(value) + value.is_a?(String) || value.is_a?(Symbol) + end + + def validate_regexp(value) + !value.nil? && Regexp.new(value.to_s) && true + rescue RegexpError, TypeError + false + end + + def validate_string_or_regexp(value) + return true if value.is_a?(Symbol) + return false unless value.is_a?(String) + + if value.first == '/' && value.last == '/' + validate_regexp(value[1...-1]) + else + true + end + end + + def validate_boolean(value) + value.in?([true, false]) + end + end + end + end +end diff --git a/lib/gitlab/config/entry/node.rb b/lib/gitlab/config/entry/node.rb new file mode 100644 index 00000000000..30357b2c95b --- /dev/null +++ b/lib/gitlab/config/entry/node.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + ## + # Base abstract class for each configuration entry node. + # + class Node + InvalidError = Class.new(StandardError) + + attr_reader :config, :metadata + attr_accessor :key, :parent, :description + + def initialize(config, **metadata) + @config = config + @metadata = metadata + @entries = {} + + self.class.aspects.to_a.each do |aspect| + instance_exec(&aspect) + end + end + + def [](key) + @entries[key] || Entry::Undefined.new + end + + def compose!(deps = nil) + return unless valid? + + yield if block_given? + end + + def leaf? + @entries.none? + end + + def descendants + @entries.values + end + + def ancestors + @parent ? @parent.ancestors + [@parent] : [] + end + + def valid? + errors.none? + end + + def errors + [] + end + + def value + if leaf? + @config + else + meaningful = @entries.select do |_key, value| + value.specified? && value.relevant? + end + + Hash[meaningful.map { |key, entry| [key, entry.value] }] + end + end + + def specified? + true + end + + def relevant? + true + end + + def location + name = @key.presence || self.class.name.to_s.demodulize + .underscore.humanize.downcase + + ancestors.map(&:key).append(name).compact.join(':') + end + + def inspect + val = leaf? ? config : descendants + unspecified = specified? ? '' : '(unspecified) ' + "#<#{self.class.name} #{unspecified}{#{key}: #{val.inspect}}>" + end + + def self.default + end + + def self.aspects + @aspects ||= [] + end + + private + + attr_reader :entries + end + end + end +end diff --git a/lib/gitlab/config/entry/simplifiable.rb b/lib/gitlab/config/entry/simplifiable.rb new file mode 100644 index 00000000000..3e148fe2e91 --- /dev/null +++ b/lib/gitlab/config/entry/simplifiable.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + class Simplifiable < SimpleDelegator + EntryStrategy = Struct.new(:name, :condition) + + def initialize(config, **metadata) + unless self.class.const_defined?(:UnknownStrategy) + raise ArgumentError, 'UndefinedStrategy not available!' + end + + strategy = self.class.strategies.find do |variant| + variant.condition.call(config) + end + + entry = self.class.entry_class(strategy) + + super(entry.new(config, metadata)) + end + + def self.strategy(name, **opts) + EntryStrategy.new(name, opts.fetch(:if)).tap do |strategy| + strategies.append(strategy) + end + end + + def self.strategies + @strategies ||= [] + end + + def self.entry_class(strategy) + if strategy.present? + self.const_get(strategy.name) + else + self::UnknownStrategy + end + end + end + end + end +end diff --git a/lib/gitlab/config/entry/undefined.rb b/lib/gitlab/config/entry/undefined.rb new file mode 100644 index 00000000000..5f708abc80c --- /dev/null +++ b/lib/gitlab/config/entry/undefined.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + ## + # This class represents an undefined entry. + # + class Undefined < Node + def initialize(*) + super(nil) + end + + def value + nil + end + + def valid? + true + end + + def errors + [] + end + + def specified? + false + end + + def relevant? + false + end + + def inspect + "#<#{self.class.name}>" + end + end + end + end +end diff --git a/lib/gitlab/config/entry/unspecified.rb b/lib/gitlab/config/entry/unspecified.rb new file mode 100644 index 00000000000..c096180d0f8 --- /dev/null +++ b/lib/gitlab/config/entry/unspecified.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + ## + # This class represents an unspecified entry. + # + # It decorates original entry adding method that indicates it is + # unspecified. + # + class Unspecified < SimpleDelegator + def specified? + false + end + end + end + end +end diff --git a/lib/gitlab/config/entry/validatable.rb b/lib/gitlab/config/entry/validatable.rb new file mode 100644 index 00000000000..1c88c68c11c --- /dev/null +++ b/lib/gitlab/config/entry/validatable.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + module Validatable + extend ActiveSupport::Concern + + def self.included(node) + node.aspects.append -> do + @validator = self.class.validator.new(self) + @validator.validate(:new) + end + end + + def errors + @validator.messages + descendants.flat_map(&:errors) # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + class_methods do + def validator + @validator ||= Class.new(Entry::Validator).tap do |validator| + if defined?(@validations) + @validations.each { |rules| validator.class_eval(&rules) } + end + end + end + + private + + def validations(&block) + (@validations ||= []).append(block) + end + end + end + end + end +end diff --git a/lib/gitlab/config/entry/validator.rb b/lib/gitlab/config/entry/validator.rb new file mode 100644 index 00000000000..e5efd4a7b0a --- /dev/null +++ b/lib/gitlab/config/entry/validator.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + class Validator < SimpleDelegator + include ActiveModel::Validations + include Entry::Validators + + def initialize(entry) + super(entry) + end + + def messages + errors.full_messages.map do |error| + "#{location} #{error}".downcase + end + end + + def self.name + 'Validator' + end + end + end + end +end diff --git a/lib/gitlab/config/entry/validators.rb b/lib/gitlab/config/entry/validators.rb new file mode 100644 index 00000000000..25bfa50f829 --- /dev/null +++ b/lib/gitlab/config/entry/validators.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Entry + module Validators + class AllowedKeysValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unknown_keys = value.try(:keys).to_a - options[:in] + + if unknown_keys.any? + record.errors.add(attribute, "contains unknown keys: " + + unknown_keys.join(', ')) + end + end + end + + class AllowedValuesValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless options[:in].include?(value.to_s) + record.errors.add(attribute, "unknown value: #{value}") + end + end + end + + class AllowedArrayValuesValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unkown_values = value - options[:in] + unless unkown_values.empty? + record.errors.add(attribute, "contains unknown values: " + + unkown_values.join(', ')) + end + end + end + + class ArrayOfStringsValidator < ActiveModel::EachValidator + include LegacyValidationHelpers + + def validate_each(record, attribute, value) + unless validate_array_of_strings(value) + record.errors.add(attribute, 'should be an array of strings') + end + end + end + + class BooleanValidator < ActiveModel::EachValidator + include LegacyValidationHelpers + + def validate_each(record, attribute, value) + unless validate_boolean(value) + record.errors.add(attribute, 'should be a boolean value') + end + end + end + + class DurationValidator < ActiveModel::EachValidator + include LegacyValidationHelpers + + def validate_each(record, attribute, value) + unless validate_duration(value) + record.errors.add(attribute, 'should be a duration') + end + + if options[:limit] + unless validate_duration_limit(value, options[:limit]) + record.errors.add(attribute, 'should not exceed the limit') + end + end + end + end + + class HashOrStringValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless value.is_a?(Hash) || value.is_a?(String) + record.errors.add(attribute, 'should be a hash or a string') + end + end + end + + class HashOrIntegerValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless value.is_a?(Hash) || value.is_a?(Integer) + record.errors.add(attribute, 'should be a hash or an integer') + end + end + end + + class KeyValidator < ActiveModel::EachValidator + include LegacyValidationHelpers + + def validate_each(record, attribute, value) + if validate_string(value) + validate_path(record, attribute, value) + else + record.errors.add(attribute, 'should be a string or symbol') + end + end + + private + + def validate_path(record, attribute, value) + path = CGI.unescape(value.to_s) + + if path.include?('/') + record.errors.add(attribute, 'cannot contain the "/" character') + elsif path == '.' || path == '..' + record.errors.add(attribute, 'cannot be "." or ".."') + end + end + end + + class RegexpValidator < ActiveModel::EachValidator + include LegacyValidationHelpers + + def validate_each(record, attribute, value) + unless validate_regexp(value) + record.errors.add(attribute, 'must be a regular expression') + end + end + + private + + def look_like_regexp?(value) + value.is_a?(String) && value.start_with?('/') && + value.end_with?('/') + end + + def validate_regexp(value) + look_like_regexp?(value) && + Regexp.new(value.to_s[1...-1]) && + true + rescue RegexpError + false + end + end + + class ArrayOfStringsOrRegexpsValidator < RegexpValidator + def validate_each(record, attribute, value) + unless validate_array_of_strings_or_regexps(value) + record.errors.add(attribute, 'should be an array of strings or regexps') + end + end + + private + + def validate_array_of_strings_or_regexps(values) + values.is_a?(Array) && values.all?(&method(:validate_string_or_regexp)) + end + + def validate_string_or_regexp(value) + return false unless value.is_a?(String) + return validate_regexp(value) if look_like_regexp?(value) + + true + end + end + + class ArrayOfStringsOrStringValidator < RegexpValidator + def validate_each(record, attribute, value) + unless validate_array_of_strings_or_string(value) + record.errors.add(attribute, 'should be an array of strings or a string') + end + end + + private + + def validate_array_of_strings_or_string(values) + validate_array_of_strings(values) || validate_string(values) + end + end + + class TypeValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + type = options[:with] + raise unless type.is_a?(Class) + + unless value.is_a?(type) + message = options[:message] || "should be a #{type.name}" + record.errors.add(attribute, message) + end + end + end + + class VariablesValidator < ActiveModel::EachValidator + include LegacyValidationHelpers + + def validate_each(record, attribute, value) + unless validate_variables(value) + record.errors.add(attribute, 'should be a hash of key value pairs') + end + end + end + end + end + end +end diff --git a/lib/gitlab/config/loader/format_error.rb b/lib/gitlab/config/loader/format_error.rb new file mode 100644 index 00000000000..848ff96d201 --- /dev/null +++ b/lib/gitlab/config/loader/format_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Loader + FormatError = Class.new(StandardError) + end + end +end diff --git a/lib/gitlab/ci/config/loader.rb b/lib/gitlab/config/loader/yaml.rb index b4c491e84a6..8159f8b8026 100644 --- a/lib/gitlab/ci/config/loader.rb +++ b/lib/gitlab/config/loader/yaml.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true module Gitlab - module Ci - class Config - class Loader - FormatError = Class.new(StandardError) - + module Config + module Loader + class Yaml def initialize(config) @config = YAML.safe_load(config, [Symbol], [], true) rescue Psych::Exception => e - raise FormatError, e.message + raise Loader::FormatError, e.message end def valid? @@ -18,7 +16,7 @@ module Gitlab def load! unless valid? - raise FormatError, 'Invalid configuration format' + raise Loader::FormatError, 'Invalid configuration format' end @config.deep_symbolize_keys diff --git a/lib/gitlab/correlation_id.rb b/lib/gitlab/correlation_id.rb new file mode 100644 index 00000000000..0f9bde4390e --- /dev/null +++ b/lib/gitlab/correlation_id.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Gitlab + module CorrelationId + LOG_KEY = 'correlation_id'.freeze + + class << self + def use_id(correlation_id, &blk) + # always generate a id if null is passed + correlation_id ||= new_id + + ids.push(correlation_id || new_id) + + begin + yield(current_id) + ensure + ids.pop + end + end + + def current_id + ids.last + end + + def current_or_new_id + current_id || new_id + end + + private + + def ids + Thread.current[:correlation_id] ||= [] + end + + def new_id + SecureRandom.uuid + end + end + end +end diff --git a/lib/gitlab/crypto_helper.rb b/lib/gitlab/crypto_helper.rb index 68d0b5d8f8a..87a03d9c58f 100644 --- a/lib/gitlab/crypto_helper.rb +++ b/lib/gitlab/crypto_helper.rb @@ -6,8 +6,8 @@ module Gitlab AES256_GCM_OPTIONS = { algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_truncated, - iv: Settings.attr_encrypted_db_key_base_truncated[0..11] + key: Settings.attr_encrypted_db_key_base_32, + iv: Settings.attr_encrypted_db_key_base_12 }.freeze def sha256(value) @@ -17,7 +17,7 @@ module Gitlab def aes256_gcm_encrypt(value) encrypted_token = Encryptor.encrypt(AES256_GCM_OPTIONS.merge(value: value)) - Base64.encode64(encrypted_token) + Base64.strict_encode64(encrypted_token) end def aes256_gcm_decrypt(value) diff --git a/lib/gitlab/database/count.rb b/lib/gitlab/database/count.rb index ea6529e2dc4..f3d37ccd72a 100644 --- a/lib/gitlab/database/count.rb +++ b/lib/gitlab/database/count.rb @@ -1,7 +1,12 @@ # frozen_string_literal: true # For large tables, PostgreSQL can take a long time to count rows due to MVCC. -# We can optimize this by using the reltuples count as described in https://wiki.postgresql.org/wiki/Slow_Counting. +# We can optimize this by using various strategies for approximate counting. +# +# For example, we can use the reltuples count as described in https://wiki.postgresql.org/wiki/Slow_Counting. +# +# However, since statistics are not always up to date, we also implement a table sampling strategy +# that performs an exact count but only on a sample of the table. See TablesampleCountStrategy. module Gitlab module Database module Count @@ -20,68 +25,30 @@ module Gitlab end # Takes in an array of models and returns a Hash for the approximate - # counts for them. If the model's table has not been vacuumed or - # analyzed recently, simply run the Model.count to get the data. + # counts for them. + # + # Various count strategies can be specified that are executed in + # sequence until all tables have an approximate count attached + # or we run out of strategies. + # + # Note that not all strategies are available on all supported RDBMS. # # @param [Array] # @return [Hash] of Model -> count mapping - def self.approximate_counts(models) - table_to_model_map = models.each_with_object({}) do |model, hash| - hash[model.table_name] = model - end - - table_names = table_to_model_map.keys - counts_by_table_name = Gitlab::Database.postgresql? ? reltuples_from_recently_updated(table_names) : {} + def self.approximate_counts(models, strategies: [TablesampleCountStrategy, ReltuplesCountStrategy, ExactCountStrategy]) + strategies.each_with_object({}) do |strategy, counts_by_model| + if strategy.enabled? + models_with_missing_counts = models - counts_by_model.keys - # Convert table -> count to Model -> count - counts_by_model = counts_by_table_name.each_with_object({}) do |pair, hash| - model = table_to_model_map[pair.first] - hash[model] = pair.second - end + break counts_by_model if models_with_missing_counts.empty? - missing_tables = table_names - counts_by_table_name.keys + counts = strategy.new(models_with_missing_counts).count - missing_tables.each do |table| - model = table_to_model_map[table] - counts_by_model[model] = model.count + counts.each do |model, count| + counts_by_model[model] = count + end + end end - - counts_by_model - end - - # Returns a hash of the table names that have recently updated tuples. - # - # @param [Array] table names - # @returns [Hash] Table name to count mapping (e.g. { 'projects' => 5, 'users' => 100 }) - def self.reltuples_from_recently_updated(table_names) - query = postgresql_estimate_query(table_names) - rows = [] - - # Querying tuple stats only works on the primary. Due to load - # balancing, we need to ensure this query hits the load balancer. The - # easiest way to do this is to start a transaction. - ActiveRecord::Base.transaction do - rows = ActiveRecord::Base.connection.select_all(query) - end - - rows.each_with_object({}) { |row, data| data[row['table_name']] = row['estimate'].to_i } - rescue *CONNECTION_ERRORS - {} - end - - # Generates the PostgreSQL query to return the tuples for tables - # that have been vacuumed or analyzed in the last hour. - # - # @param [Array] table names - # @returns [Hash] Table name to count mapping (e.g. { 'projects' => 5, 'users' => 100 }) - def self.postgresql_estimate_query(table_names) - time = "to_timestamp(#{1.hour.ago.to_i})" - <<~SQL - SELECT pg_class.relname AS table_name, reltuples::bigint AS estimate FROM pg_class - LEFT JOIN pg_stat_user_tables ON pg_class.relname = pg_stat_user_tables.relname - WHERE pg_class.relname IN (#{table_names.map { |table| "'#{table}'" }.join(',')}) - AND (last_vacuum > #{time} OR last_autovacuum > #{time} OR last_analyze > #{time} OR last_autoanalyze > #{time}) - SQL end end end diff --git a/lib/gitlab/database/count/exact_count_strategy.rb b/lib/gitlab/database/count/exact_count_strategy.rb new file mode 100644 index 00000000000..fa6951eda22 --- /dev/null +++ b/lib/gitlab/database/count/exact_count_strategy.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Gitlab + module Database + module Count + # This strategy performs an exact count on the model. + # + # This is guaranteed to be accurate, however it also scans the + # whole table. Hence, there are no guarantees with respect + # to runtime. + # + # Note that for very large tables, this may even timeout. + class ExactCountStrategy + attr_reader :models + def initialize(models) + @models = models + end + + def count + models.each_with_object({}) do |model, data| + data[model] = model.count + end + rescue *CONNECTION_ERRORS + {} + end + + def self.enabled? + true + end + end + end + end +end diff --git a/lib/gitlab/database/count/reltuples_count_strategy.rb b/lib/gitlab/database/count/reltuples_count_strategy.rb new file mode 100644 index 00000000000..c3a674aeb7e --- /dev/null +++ b/lib/gitlab/database/count/reltuples_count_strategy.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Gitlab + module Database + module Count + class PgClass < ActiveRecord::Base + self.table_name = 'pg_class' + end + + # This strategy counts based on PostgreSQL's statistics in pg_stat_user_tables. + # + # Specifically, it relies on the column reltuples in said table. An additional + # check is performed to make sure statistics were updated within the last hour. + # + # Otherwise, this strategy skips tables with outdated statistics. + # + # There are no guarantees with respect to the accuracy of this strategy. Runtime + # however is guaranteed to be "fast", because it only looks up statistics. + class ReltuplesCountStrategy + attr_reader :models + def initialize(models) + @models = models + end + + # Returns a hash of the table names that have recently updated tuples. + # + # @returns [Hash] Table name to count mapping (e.g. { 'projects' => 5, 'users' => 100 }) + def count + size_estimates + rescue *CONNECTION_ERRORS + {} + end + + def self.enabled? + Gitlab::Database.postgresql? + end + + private + + def table_names + models.map(&:table_name) + end + + def size_estimates(check_statistics: true) + table_to_model = models.each_with_object({}) { |model, h| h[model.table_name] = model } + + # Querying tuple stats only works on the primary. Due to load balancing, the + # easiest way to do this is to start a transaction. + ActiveRecord::Base.transaction do + get_statistics(table_names, check_statistics: check_statistics).each_with_object({}) do |row, data| + model = table_to_model[row.table_name] + data[model] = row.estimate + end + end + end + + # Generates the PostgreSQL query to return the tuples for tables + # that have been vacuumed or analyzed in the last hour. + # + # @param [Array] table names + # @returns [Hash] Table name to count mapping (e.g. { 'projects' => 5, 'users' => 100 }) + def get_statistics(table_names, check_statistics: true) + time = 1.hour.ago + + query = PgClass.joins("LEFT JOIN pg_stat_user_tables USING (relname)") + .where(relname: table_names) + .select('pg_class.relname AS table_name, reltuples::bigint AS estimate') + + if check_statistics + query = query.where('last_vacuum > ? OR last_autovacuum > ? OR last_analyze > ? OR last_autoanalyze > ?', + time, time, time, time) + end + + query + end + end + end + end +end diff --git a/lib/gitlab/database/count/tablesample_count_strategy.rb b/lib/gitlab/database/count/tablesample_count_strategy.rb new file mode 100644 index 00000000000..cf1cf054dbf --- /dev/null +++ b/lib/gitlab/database/count/tablesample_count_strategy.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Gitlab + module Database + module Count + # A tablesample count executes in two phases: + # * Estimate table sizes based on reltuples. + # * Based on the estimate: + # * If the table is considered 'small', execute an exact relation count. + # * Otherwise, count on a sample of the table using TABLESAMPLE. + # + # The size of the sample is chosen in a way that we always roughly scan + # the same amount of rows (see TABLESAMPLE_ROW_TARGET). + # + # There are no guarantees with respect to the accuracy of the result or runtime. + class TablesampleCountStrategy < ReltuplesCountStrategy + EXACT_COUNT_THRESHOLD = 10_000 + TABLESAMPLE_ROW_TARGET = 10_000 + + def count + estimates = size_estimates(check_statistics: false) + + models.each_with_object({}) do |model, count_by_model| + count = perform_count(model, estimates[model]) + count_by_model[model] = count if count + end + rescue *CONNECTION_ERRORS + {} + end + + def self.enabled? + Gitlab::Database.postgresql? && Feature.enabled?(:tablesample_counts) + end + + private + + def perform_count(model, estimate) + # If we estimate 0, we may not have statistics at all. Don't use them. + return nil unless estimate && estimate > 0 + + if estimate < EXACT_COUNT_THRESHOLD + # The table is considered small, the assumption here is that + # the exact count will be fast anyways. + model.count + else + # The table is considered large, let's only count on a sample. + tablesample_count(model, estimate) + end + end + + def tablesample_count(model, estimate) + portion = (TABLESAMPLE_ROW_TARGET.to_f / estimate).round(4) + inverse = 1 / portion + query = <<~SQL + SELECT (COUNT(*)*#{inverse})::integer AS count + FROM #{model.table_name} TABLESAMPLE SYSTEM (#{portion * 100}) + SQL + + rows = ActiveRecord::Base.connection.select_all(query) + + Integer(rows.first['count']) + end + end + end + end +end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 134d1e7a724..d9578852db6 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -975,9 +975,10 @@ into similar problems in the future (e.g. when new tables are created). raise "#{model_class} does not have an ID to use for batch ranges" unless model_class.column_names.include?('id') jobs = [] + table_name = model_class.quoted_table_name model_class.each_batch(of: batch_size) do |relation| - start_id, end_id = relation.pluck('MIN(id), MAX(id)').first + start_id, end_id = relation.pluck("MIN(#{table_name}.id), MAX(#{table_name}.id)").first if jobs.length >= BACKGROUND_MIGRATION_JOB_BUFFER_SIZE # Note: This code path generally only helps with many millions of rows diff --git a/lib/gitlab/diff/file_collection/base.rb b/lib/gitlab/diff/file_collection/base.rb index 10df037a0dd..c5bbf522f7c 100644 --- a/lib/gitlab/diff/file_collection/base.rb +++ b/lib/gitlab/diff/file_collection/base.rb @@ -34,6 +34,16 @@ module Gitlab @diff_files ||= diffs.decorate! { |diff| decorate_diff!(diff) } end + # This mutates `diff_files` lines. + def unfold_diff_files(positions) + positions_grouped_by_path = positions.group_by { |position| position.file_path } + + diff_files.each do |diff_file| + positions = positions_grouped_by_path.fetch(diff_file.file_path, []) + positions.each { |position| diff_file.unfold_diff_lines(position) } + end + end + def diff_file_with_old_path(old_path) diff_files.find { |diff_file| diff_file.old_path == old_path } end diff --git a/lib/gitlab/diff/file_collection/compare.rb b/lib/gitlab/diff/file_collection/compare.rb index 586c5cf87af..663bad95db7 100644 --- a/lib/gitlab/diff/file_collection/compare.rb +++ b/lib/gitlab/diff/file_collection/compare.rb @@ -10,6 +10,10 @@ module Gitlab diff_options: diff_options, diff_refs: diff_refs) end + + def unfold_diff_lines(positions) + # no-op + end end end end diff --git a/lib/gitlab/file_finder.rb b/lib/gitlab/file_finder.rb index b4db3f93c9c..3958814208c 100644 --- a/lib/gitlab/file_finder.rb +++ b/lib/gitlab/file_finder.rb @@ -4,8 +4,6 @@ # the result is joined and sorted by file name module Gitlab class FileFinder - BATCH_SIZE = 100 - attr_reader :project, :ref delegate :repository, to: :project @@ -16,60 +14,35 @@ module Gitlab end def find(query) - query = Gitlab::Search::Query.new(query) do - filter :filename, matcher: ->(filter, blob) { blob.filename =~ /#{filter[:regex_value]}$/i } - filter :path, matcher: ->(filter, blob) { blob.filename =~ /#{filter[:regex_value]}/i } - filter :extension, matcher: ->(filter, blob) { blob.filename =~ /\.#{filter[:regex_value]}$/i } + query = Gitlab::Search::Query.new(query, encode_binary: true) do + filter :filename, matcher: ->(filter, blob) { blob.binary_filename =~ /#{filter[:regex_value]}$/i } + filter :path, matcher: ->(filter, blob) { blob.binary_filename =~ /#{filter[:regex_value]}/i } + filter :extension, matcher: ->(filter, blob) { blob.binary_filename =~ /\.#{filter[:regex_value]}$/i } end - by_content = find_by_content(query.term) - - already_found = Set.new(by_content.map(&:filename)) - by_filename = find_by_filename(query.term, except: already_found) + files = find_by_filename(query.term) + find_by_content(query.term) - files = (by_content + by_filename) - .sort_by(&:filename) + files = query.filter_results(files) if query.filters.any? - query.filter_results(files).map { |blob| [blob.filename, blob] } + files end private def find_by_content(query) - results = repository.search_files_by_content(query, ref).first(BATCH_SIZE) - results.map { |result| Gitlab::ProjectSearchResults.parse_search_result(result, project) } - end - - def find_by_filename(query, except: []) - filenames = search_filenames(query, except) - - blobs(filenames).map do |blob| - Gitlab::SearchResults::FoundBlob.new( - id: blob.id, - filename: blob.path, - basename: File.basename(blob.path, File.extname(blob.path)), - ref: ref, - startline: 1, - data: blob.data, - project: project - ) + repository.search_files_by_content(query, ref).map do |result| + Gitlab::Search::FoundBlob.new(content_match: result, project: project, ref: ref, repository: repository) end end - def search_filenames(query, except) - filenames = repository.search_files_by_name(query, ref).first(BATCH_SIZE) - - filenames.delete_if { |filename| except.include?(filename) } unless except.empty? - - filenames - end - - def blob_refs(filenames) - filenames.map { |filename| [ref, filename] } + def find_by_filename(query) + search_filenames(query).map do |filename| + Gitlab::Search::FoundBlob.new(blob_filename: filename, project: project, ref: ref, repository: repository) + end end - def blobs(filenames) - Gitlab::Git::Blob.batch(repository, blob_refs(filenames), blob_size_limit: 1024) + def search_filenames(query) + repository.search_files_by_name(query, ref) end end end diff --git a/lib/gitlab/git/repository_cleaner.rb b/lib/gitlab/git/repository_cleaner.rb new file mode 100644 index 00000000000..2d1d8435cf3 --- /dev/null +++ b/lib/gitlab/git/repository_cleaner.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Gitlab + module Git + class RepositoryCleaner + include Gitlab::Git::WrapsGitalyErrors + + attr_reader :repository + + # 'repository' is a Gitlab::Git::Repository + def initialize(repository) + @repository = repository + end + + def apply_bfg_object_map(io) + wrapped_gitaly_errors do + gitaly_cleanup_client.apply_bfg_object_map(io) + end + end + + private + + def gitaly_cleanup_client + @gitaly_cleanup_client ||= Gitlab::GitalyClient::CleanupService.new(repository) + end + end + end +end diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index 9be553a8b86..255601382b1 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -193,6 +193,7 @@ module Gitlab feature = feature_stack && feature_stack[0] metadata['call_site'] = feature.to_s if feature metadata['gitaly-servers'] = address_metadata(remote_storage) if remote_storage + metadata['correlation_id'] = Gitlab::CorrelationId.current_id if Gitlab::CorrelationId.current_id metadata.merge!(server_feature_flags) diff --git a/lib/gitlab/gitaly_client/cleanup_service.rb b/lib/gitlab/gitaly_client/cleanup_service.rb new file mode 100644 index 00000000000..8e412a9b3ef --- /dev/null +++ b/lib/gitlab/gitaly_client/cleanup_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Gitlab + module GitalyClient + class CleanupService + attr_reader :repository, :gitaly_repo, :storage + + # 'repository' is a Gitlab::Git::Repository + def initialize(repository) + @repository = repository + @gitaly_repo = repository.gitaly_repository + @storage = repository.storage + end + + def apply_bfg_object_map(io) + first_request = Gitaly::ApplyBfgObjectMapRequest.new(repository: gitaly_repo) + + enum = Enumerator.new do |y| + y.yield first_request + + while data = io.read(RepositoryService::MAX_MSG_SIZE) + y.yield Gitaly::ApplyBfgObjectMapRequest.new(object_map: data) + end + end + + GitalyClient.call( + storage, + :cleanup_service, + :apply_bfg_object_map, + enum, + timeout: GitalyClient.no_timeout + ) + end + end + end +end diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb index c32c2c0b2fb..22d2d149e65 100644 --- a/lib/gitlab/gitaly_client/operation_service.rb +++ b/lib/gitlab/gitaly_client/operation_service.rb @@ -385,7 +385,8 @@ module Gitlab file_path: encode_binary(action[:file_path]), previous_path: encode_binary(action[:previous_path]), base64_content: action[:encoding] == 'base64', - execute_filemode: !!action[:execute_filemode] + execute_filemode: !!action[:execute_filemode], + infer_content: !!action[:infer_content] ) rescue RangeError raise ArgumentError, "Unknown action '#{action[:action]}'" diff --git a/lib/gitlab/github_import/importer/repository_importer.rb b/lib/gitlab/github_import/importer/repository_importer.rb index 374dc9d3c00..bc3ea9e9226 100644 --- a/lib/gitlab/github_import/importer/repository_importer.rb +++ b/lib/gitlab/github_import/importer/repository_importer.rb @@ -80,7 +80,7 @@ module Gitlab end def fail_import(message) - project.mark_import_as_failed(message) + project.import_state.mark_as_failed(message) false end end diff --git a/lib/gitlab/github_import/parallel_importer.rb b/lib/gitlab/github_import/parallel_importer.rb index a77ac1e4fa6..9d81441d96e 100644 --- a/lib/gitlab/github_import/parallel_importer.rb +++ b/lib/gitlab/github_import/parallel_importer.rb @@ -41,8 +41,7 @@ module Gitlab Gitlab::SidekiqStatus .set(jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION) - project.ensure_import_state - project.import_state&.update_column(:jid, jid) + project.import_state.update_column(:jid, jid) Stage::ImportRepositoryWorker .perform_async(project.id) diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 31bab20b044..4fbb87385c3 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -44,9 +44,8 @@ module Gitlab def update_signature!(cached_signature) using_keychain do |gpg_key| cached_signature.update!(attributes(gpg_key)) + @signature = cached_signature end - - @signature = cached_signature end private @@ -59,11 +58,15 @@ module Gitlab # the proper signature. # NOTE: the invoked method is #fingerprint but it's only returning # 16 characters (the format used by keyid) instead of 40. - gpg_key = find_gpg_key(verified_signature.fingerprint) + fingerprint = verified_signature&.fingerprint + + break unless fingerprint + + gpg_key = find_gpg_key(fingerprint) if gpg_key Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key) - @verified_signature = nil + clear_memoization(:verified_signature) end yield gpg_key @@ -71,9 +74,16 @@ module Gitlab end def verified_signature - @verified_signature ||= GPGME::Crypto.new.verify(signature_text, signed_text: signed_text) do |verified_signature| + strong_memoize(:verified_signature) { gpgme_signature } + end + + def gpgme_signature + GPGME::Crypto.new.verify(signature_text, signed_text: signed_text) do |verified_signature| + # Return the first signature for now: https://gitlab.com/gitlab-org/gitlab-ce/issues/54932 break verified_signature end + rescue GPGME::Error + nil end def create_cached_signature! @@ -92,7 +102,7 @@ module Gitlab commit_sha: @commit.sha, project: @commit.project, gpg_key: gpg_key, - gpg_key_primary_keyid: gpg_key&.keyid || verified_signature.fingerprint, + gpg_key_primary_keyid: gpg_key&.keyid || verified_signature&.fingerprint, gpg_key_user_name: user_infos[:name], gpg_key_user_email: user_infos[:email], verification_status: verification_status @@ -102,7 +112,7 @@ module Gitlab def verification_status(gpg_key) return :unknown_key unless gpg_key return :unverified_key unless gpg_key.verified? - return :unverified unless verified_signature.valid? + return :unverified unless verified_signature&.valid? if gpg_key.verified_and_belongs_to_email?(@commit.committer_email) :verified diff --git a/lib/gitlab/grape_logging/loggers/correlation_id_logger.rb b/lib/gitlab/grape_logging/loggers/correlation_id_logger.rb new file mode 100644 index 00000000000..fa4c5d86d44 --- /dev/null +++ b/lib/gitlab/grape_logging/loggers/correlation_id_logger.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# This module adds additional correlation id the grape logger +module Gitlab + module GrapeLogging + module Loggers + class CorrelationIdLogger < ::GrapeLogging::Loggers::Base + def parameters(_, _) + { Gitlab::CorrelationId::LOG_KEY => Gitlab::CorrelationId.current_id } + end + end + end + end +end diff --git a/lib/gitlab/group_hierarchy.rb b/lib/gitlab/group_hierarchy.rb index c940ea7305e..97cbdc6cb39 100644 --- a/lib/gitlab/group_hierarchy.rb +++ b/lib/gitlab/group_hierarchy.rb @@ -34,8 +34,8 @@ module Gitlab # reached. So all ancestors *lower* than the specified ancestor will be # included. # rubocop: disable CodeReuse/ActiveRecord - def ancestors(upto: nil) - base_and_ancestors(upto: upto).where.not(id: ancestors_base.select(:id)) + def ancestors(upto: nil, hierarchy_order: nil) + base_and_ancestors(upto: upto, hierarchy_order: hierarchy_order).where.not(id: ancestors_base.select(:id)) end # rubocop: enable CodeReuse/ActiveRecord @@ -45,11 +45,22 @@ module Gitlab # Passing an `upto` will stop the recursion once the specified parent_id is # reached. So all ancestors *lower* than the specified acestor will be # included. - def base_and_ancestors(upto: nil) + # + # Passing a `hierarchy_order` with either `:asc` or `:desc` will cause the + # recursive query order from most nested group to root or from the root + # ancestor to most nested group respectively. This uses a `depth` column + # where `1` is defined as the depth for the base and increment as we go up + # each parent. + # rubocop: disable CodeReuse/ActiveRecord + def base_and_ancestors(upto: nil, hierarchy_order: nil) return ancestors_base unless Group.supports_nested_groups? - read_only(base_and_ancestors_cte(upto).apply_to(model.all)) + recursive_query = base_and_ancestors_cte(upto, hierarchy_order).apply_to(model.all) + recursive_query = recursive_query.order(depth: hierarchy_order) if hierarchy_order + + read_only(recursive_query) end + # rubocop: enable CodeReuse/ActiveRecord # Returns a relation that includes the descendants_base set of groups # and all their descendants (recursively). @@ -107,16 +118,22 @@ module Gitlab private # rubocop: disable CodeReuse/ActiveRecord - def base_and_ancestors_cte(stop_id = nil) + def base_and_ancestors_cte(stop_id = nil, hierarchy_order = nil) cte = SQL::RecursiveCTE.new(:base_and_ancestors) + depth_column = :depth + + base_query = ancestors_base.except(:order) + base_query = base_query.select("1 as #{depth_column}", groups_table[Arel.star]) if hierarchy_order - cte << ancestors_base.except(:order) + cte << base_query # Recursively get all the ancestors of the base set. parent_query = model .from([groups_table, cte.table]) .where(groups_table[:id].eq(cte.table[:parent_id])) .except(:order) + + parent_query = parent_query.select(cte.table[depth_column] + 1, groups_table[Arel.star]) if hierarchy_order parent_query = parent_query.where(cte.table[:parent_id].not_eq(stop_id)) if stop_id cte << parent_query diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index b40eac3de9a..d10d4f2f746 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -51,7 +51,7 @@ project_tree: - resource_label_events: - label: :priorities - - pipelines: + - ci_pipelines: - notes: - :author - events: @@ -98,13 +98,12 @@ excluded_attributes: - :avatar - :import_type - :import_source - - :import_error - :mirror - :runners_token + - :runners_token_encrypted - :repository_storage - :repository_read_only - :lfs_enabled - - :import_jid - :created_at - :updated_at - :id @@ -116,6 +115,13 @@ excluded_attributes: - :remote_mirror_available_overridden - :description_html - :repository_languages + - :bfg_object_map + namespaces: + - :runners_token + - :runners_token_encrypted + project_import_state: + - :last_error + - :jid prometheus_metrics: - :common - :identifier @@ -137,6 +143,7 @@ excluded_attributes: statuses: - :trace - :token + - :token_encrypted - :when - :artifacts_file - :artifacts_metadata @@ -154,6 +161,9 @@ excluded_attributes: - :encrypted_token_iv - :encrypted_url - :encrypted_url_iv + runners: + - :token + - :token_encrypted services: - :template diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index 8cd4efd91cc..a56ec65b9f1 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -26,6 +26,8 @@ module Gitlab @project_members = @tree_hash.delete('project_members') + RelationRenameService.rename(@tree_hash) + ActiveRecord::Base.uncached do ActiveRecord::Base.no_touching do create_relations @@ -214,7 +216,7 @@ module Gitlab end def nil_iid_pipeline?(relation_key, relation_item) - relation_key == 'pipelines' && relation_item['iid'].nil? + relation_key == 'ci_pipelines' && relation_item['iid'].nil? end end end diff --git a/lib/gitlab/import_export/project_tree_saver.rb b/lib/gitlab/import_export/project_tree_saver.rb index 29f2dc80813..2255635acdf 100644 --- a/lib/gitlab/import_export/project_tree_saver.rb +++ b/lib/gitlab/import_export/project_tree_saver.rb @@ -34,6 +34,8 @@ module Gitlab project_json['project_members'] += group_members_json + RelationRenameService.add_new_associations(project_json) + project_json.to_json end diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 097c7653754..a4902e2104f 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -4,12 +4,14 @@ module Gitlab module ImportExport class RelationFactory OVERRIDES = { snippets: :project_snippets, + ci_pipelines: 'Ci::Pipeline', pipelines: 'Ci::Pipeline', stages: 'Ci::Stage', statuses: 'commit_status', triggers: 'Ci::Trigger', pipeline_schedules: 'Ci::PipelineSchedule', builds: 'Ci::Build', + runners: 'Ci::Runner', hooks: 'ProjectHook', merge_access_levels: 'ProtectedBranch::MergeAccessLevel', push_access_levels: 'ProtectedBranch::PushAccessLevel', @@ -33,7 +35,7 @@ module Gitlab EXISTING_OBJECT_CHECK = %i[milestone milestones label labels project_label project_labels group_label group_labels project_feature].freeze - TOKEN_RESET_MODELS = %w[Ci::Trigger Ci::Build ProjectHook].freeze + TOKEN_RESET_MODELS = %w[Project Namespace Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze def self.create(*args) new(*args).create diff --git a/lib/gitlab/import_export/relation_rename_service.rb b/lib/gitlab/import_export/relation_rename_service.rb new file mode 100644 index 00000000000..179bde5e21e --- /dev/null +++ b/lib/gitlab/import_export/relation_rename_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# This class is intended to help with relation renames within Gitlab versions +# and allow compatibility between versions. +# If you have to change one relationship name that is imported/exported, +# you should add it to the RENAMES constant indicating the old name and the +# new one. +# The behavior of these renamed relationships should be transient and it should +# only last one release until you completely remove the renaming from the list. +# +# When importing, this class will check the project hash and: +# - if only the old relationship name is found, it will rename it with the new one +# - if only the new relationship name is found, it will do nothing +# - if it finds both, it will use the new relationship data +# +# When exporting, this class will duplicate the keys in the resulting file. +# This way, if we open the file in an old version of the exporter it will work +# and also it will with the newer versions. +module Gitlab + module ImportExport + class RelationRenameService + RENAMES = { + 'pipelines' => 'ci_pipelines' # Added in 11.6, remove in 11.7 + }.freeze + + def self.rename(tree_hash) + return unless tree_hash&.present? + + RENAMES.each do |old_name, new_name| + old_entry = tree_hash.delete(old_name) + + next if tree_hash[new_name] + next unless old_entry + + tree_hash[new_name] = old_entry + end + end + + def self.add_new_associations(tree_hash) + RENAMES.each do |old_name, new_name| + next if tree_hash.key?(old_name) + + tree_hash[old_name] = tree_hash[new_name] + end + end + end + end +end diff --git a/lib/gitlab/json_logger.rb b/lib/gitlab/json_logger.rb index 3bff77731f6..a5a5759cc89 100644 --- a/lib/gitlab/json_logger.rb +++ b/lib/gitlab/json_logger.rb @@ -10,6 +10,7 @@ module Gitlab data = {} data[:severity] = severity data[:time] = timestamp.utc.iso8601(3) + data[Gitlab::CorrelationId::LOG_KEY] = Gitlab::CorrelationId.current_id case message when String diff --git a/lib/gitlab/kubernetes.rb b/lib/gitlab/kubernetes.rb index 3748fd6b5ef..a9957a85d48 100644 --- a/lib/gitlab/kubernetes.rb +++ b/lib/gitlab/kubernetes.rb @@ -85,6 +85,8 @@ module Gitlab end def to_kubeconfig(url:, namespace:, token:, ca_pem: nil) + return unless token.present? + config = { apiVersion: 'v1', clusters: [ @@ -113,7 +115,7 @@ module Gitlab kubeconfig_embed_ca_pem(config, ca_pem) if ca_pem - config.deep_stringify_keys + YAML.dump(config.deep_stringify_keys) end private diff --git a/lib/gitlab/kubernetes/kube_client.rb b/lib/gitlab/kubernetes/kube_client.rb index b947f6b551e..fe839940f74 100644 --- a/lib/gitlab/kubernetes/kube_client.rb +++ b/lib/gitlab/kubernetes/kube_client.rb @@ -46,6 +46,7 @@ module Gitlab :create_secret, :create_service_account, :update_config_map, + :update_secret, :update_service_account, to: :core_client @@ -80,8 +81,64 @@ module Gitlab @kubeclient_options = kubeclient_options end + def create_or_update_cluster_role_binding(resource) + if cluster_role_binding_exists?(resource) + update_cluster_role_binding(resource) + else + create_cluster_role_binding(resource) + end + end + + def create_or_update_role_binding(resource) + if role_binding_exists?(resource) + update_role_binding(resource) + else + create_role_binding(resource) + end + end + + def create_or_update_service_account(resource) + if service_account_exists?(resource) + update_service_account(resource) + else + create_service_account(resource) + end + end + + def create_or_update_secret(resource) + if secret_exists?(resource) + update_secret(resource) + else + create_secret(resource) + end + end + private + def cluster_role_binding_exists?(resource) + get_cluster_role_binding(resource.metadata.name) + rescue ::Kubeclient::ResourceNotFoundError + false + end + + def role_binding_exists?(resource) + get_role_binding(resource.metadata.name, resource.metadata.namespace) + rescue ::Kubeclient::ResourceNotFoundError + false + end + + def service_account_exists?(resource) + get_service_account(resource.metadata.name, resource.metadata.namespace) + rescue ::Kubeclient::ResourceNotFoundError + false + end + + def secret_exists?(resource) + get_secret(resource.metadata.name, resource.metadata.namespace) + rescue ::Kubeclient::ResourceNotFoundError + false + end + def build_kubeclient(api_group, api_version) ::Kubeclient::Client.new( join_api_url(api_prefix, api_group), diff --git a/lib/gitlab/legacy_github_import/importer.rb b/lib/gitlab/legacy_github_import/importer.rb index 43695451b87..c526d31a591 100644 --- a/lib/gitlab/legacy_github_import/importer.rb +++ b/lib/gitlab/legacy_github_import/importer.rb @@ -80,8 +80,7 @@ module Gitlab def handle_errors return unless errors.any? - project.ensure_import_state - project.import_state&.update_column(:last_error, { + project.import_state.update_column(:last_error, { message: 'The remote data could not be fully imported.', errors: errors }.to_json) diff --git a/lib/gitlab/lfs_token.rb b/lib/gitlab/lfs_token.rb index fa44bd842b2..05d3096a208 100644 --- a/lib/gitlab/lfs_token.rb +++ b/lib/gitlab/lfs_token.rb @@ -38,7 +38,7 @@ module Gitlab end def type - actor.is_a?(User) ? :lfs_token : :lfs_deploy_token + user? ? :lfs_token : :lfs_deploy_token end def actor_name diff --git a/lib/gitlab/middleware/correlation_id.rb b/lib/gitlab/middleware/correlation_id.rb new file mode 100644 index 00000000000..73542dd422e --- /dev/null +++ b/lib/gitlab/middleware/correlation_id.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# A dumb middleware that steals correlation id +# and sets it as a global context for the request +module Gitlab + module Middleware + class CorrelationId + include ActionView::Helpers::TagHelper + + def initialize(app) + @app = app + end + + def call(env) + ::Gitlab::CorrelationId.use_id(correlation_id(env)) do + @app.call(env) + end + end + + private + + def correlation_id(env) + if Gitlab.rails5? + request(env).request_id + else + request(env).uuid + end + end + + def request(env) + ActionDispatch::Request.new(env) + end + end + end +end diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 04df881bf03..a68f8801c2a 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -17,9 +17,9 @@ module Gitlab when 'notes' notes.page(page).per(per_page) when 'blobs' - Kaminari.paginate_array(blobs).page(page).per(per_page) + paginated_blobs(blobs, page) when 'wiki_blobs' - Kaminari.paginate_array(wiki_blobs).page(page).per(per_page) + paginated_blobs(wiki_blobs, page) when 'commits' Kaminari.paginate_array(commits).page(page).per(per_page) else @@ -55,37 +55,6 @@ module Gitlab @commits_count ||= commits.count end - def self.parse_search_result(result, project = nil) - ref = nil - filename = nil - basename = nil - - data = [] - startline = 0 - - result.each_line.each_with_index do |line, index| - prefix ||= line.match(/^(?<ref>[^:]*):(?<filename>[^\x00]*)\x00(?<startline>\d+)\x00/)&.tap do |matches| - ref = matches[:ref] - filename = matches[:filename] - startline = matches[:startline] - startline = startline.to_i - index - extname = Regexp.escape(File.extname(filename)) - basename = filename.sub(/#{extname}$/, '') - end - - data << line.sub(prefix.to_s, '') - end - - FoundBlob.new( - filename: filename, - basename: basename, - ref: ref, - startline: startline, - data: data.join, - project: project - ) - end - def single_commit_result? return false if commits_count != 1 @@ -97,6 +66,14 @@ module Gitlab private + def paginated_blobs(blobs, page) + results = Kaminari.paginate_array(blobs).page(page).per(per_page) + + Gitlab::Search::FoundBlob.preload_blobs(results) + + results + end + def blobs return [] unless Ability.allowed?(@current_user, :download_code, @project) diff --git a/lib/gitlab/search/found_blob.rb b/lib/gitlab/search/found_blob.rb new file mode 100644 index 00000000000..a62ab1521a7 --- /dev/null +++ b/lib/gitlab/search/found_blob.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +module Gitlab + module Search + class FoundBlob + include EncodingHelper + include Presentable + include BlobLanguageFromGitAttributes + include Gitlab::Utils::StrongMemoize + + attr_reader :project, :content_match, :blob_filename + + FILENAME_REGEXP = /\A(?<ref>[^:]*):(?<filename>[^\x00]*)\x00/.freeze + CONTENT_REGEXP = /^(?<ref>[^:]*):(?<filename>[^\x00]*)\x00(?<startline>\d+)\x00/.freeze + + def self.preload_blobs(blobs) + to_fetch = blobs.select { |blob| blob.is_a?(self) && blob.blob_filename } + + to_fetch.each { |blob| blob.fetch_blob } + end + + def initialize(opts = {}) + @id = opts.fetch(:id, nil) + @binary_filename = opts.fetch(:filename, nil) + @binary_basename = opts.fetch(:basename, nil) + @ref = opts.fetch(:ref, nil) + @startline = opts.fetch(:startline, nil) + @binary_data = opts.fetch(:data, nil) + @per_page = opts.fetch(:per_page, 20) + @project = opts.fetch(:project, nil) + # Some caller does not have project object (e.g. elastic search), + # yet they can trigger many calls in one go, + # causing duplicated queries. + # Allow those to just pass project_id instead. + @project_id = opts.fetch(:project_id, nil) + @content_match = opts.fetch(:content_match, nil) + @blob_filename = opts.fetch(:blob_filename, nil) + @repository = opts.fetch(:repository, nil) + end + + def id + @id ||= parsed_content[:id] + end + + def ref + @ref ||= parsed_content[:ref] + end + + def startline + @startline ||= parsed_content[:startline] + end + + # binary_filename is used for running filters on all matches, + # for grepped results (which use content_match), we get + # filename from the beginning of the grepped result which is faster + # then parsing whole snippet + def binary_filename + @binary_filename ||= content_match ? search_result_filename : parsed_content[:binary_filename] + end + + def filename + @filename ||= encode_utf8(@binary_filename || parsed_content[:binary_filename]) + end + + def basename + @basename ||= encode_utf8(@binary_basename || parsed_content[:binary_basename]) + end + + def data + @data ||= encode_utf8(@binary_data || parsed_content[:binary_data]) + end + + def path + filename + end + + def project_id + @project_id || @project&.id + end + + def present + super(presenter_class: BlobPresenter) + end + + def fetch_blob + path = [ref, blob_filename] + missing_blob = { binary_filename: blob_filename } + + BatchLoader.for(path).batch(default_value: missing_blob) do |refs, loader| + Gitlab::Git::Blob.batch(repository, refs, blob_size_limit: 1024).each do |blob| + # if the blob couldn't be fetched for some reason, + # show at least the blob filename + data = { + id: blob.id, + binary_filename: blob.path, + binary_basename: File.basename(blob.path, File.extname(blob.path)), + ref: ref, + startline: 1, + binary_data: blob.data, + project: project + } + + loader.call([ref, blob.path], data) + end + end + end + + private + + def search_result_filename + content_match.match(FILENAME_REGEXP) { |matches| matches[:filename] } + end + + def parsed_content + strong_memoize(:parsed_content) do + if content_match + parse_search_result + elsif blob_filename + fetch_blob + else + {} + end + end + end + + def parse_search_result + ref = nil + filename = nil + basename = nil + + data = [] + startline = 0 + + content_match.each_line.each_with_index do |line, index| + prefix ||= line.match(CONTENT_REGEXP)&.tap do |matches| + ref = matches[:ref] + filename = matches[:filename] + startline = matches[:startline] + startline = startline.to_i - index + extname = Regexp.escape(File.extname(filename)) + basename = filename.sub(/#{extname}$/, '') + end + + data << line.sub(prefix.to_s, '') + end + + { + binary_filename: filename, + binary_basename: basename, + ref: ref, + startline: startline, + binary_data: data.join, + project: project + } + end + + def repository + @repository ||= project.repository + end + end + end +end diff --git a/lib/gitlab/search/query.rb b/lib/gitlab/search/query.rb index 7f69083a492..ba0e16607a6 100644 --- a/lib/gitlab/search/query.rb +++ b/lib/gitlab/search/query.rb @@ -3,6 +3,8 @@ module Gitlab module Search class Query < SimpleDelegator + include EncodingHelper + def initialize(query, filter_opts = {}, &block) @raw_query = query.dup @filters = [] @@ -50,7 +52,9 @@ module Gitlab end def parse_filter(filter, input) - filter[:parser].call(input) + result = filter[:parser].call(input) + + @filter_options[:encode_binary] ? encode_binary(result) : result end end end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 458737f31eb..491148ec1a6 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -2,42 +2,6 @@ module Gitlab class SearchResults - class FoundBlob - include EncodingHelper - include Presentable - include BlobLanguageFromGitAttributes - - attr_reader :id, :filename, :basename, :ref, :startline, :data, :project - - def initialize(opts = {}) - @id = opts.fetch(:id, nil) - @filename = encode_utf8(opts.fetch(:filename, nil)) - @basename = encode_utf8(opts.fetch(:basename, nil)) - @ref = opts.fetch(:ref, nil) - @startline = opts.fetch(:startline, nil) - @data = encode_utf8(opts.fetch(:data, nil)) - @per_page = opts.fetch(:per_page, 20) - @project = opts.fetch(:project, nil) - # Some caller does not have project object (e.g. elastic search), - # yet they can trigger many calls in one go, - # causing duplicated queries. - # Allow those to just pass project_id instead. - @project_id = opts.fetch(:project_id, nil) - end - - def path - filename - end - - def project_id - @project_id || @project&.id - end - - def present - super(presenter_class: BlobPresenter) - end - end - attr_reader :current_user, :query, :per_page # Limit search results by passed projects diff --git a/lib/gitlab/sentry.rb b/lib/gitlab/sentry.rb index 8079c5882c4..46d01964eac 100644 --- a/lib/gitlab/sentry.rb +++ b/lib/gitlab/sentry.rb @@ -3,7 +3,8 @@ module Gitlab module Sentry def self.enabled? - Rails.env.production? && Gitlab::CurrentSettings.sentry_enabled? + (Rails.env.production? || Rails.env.development?) && + Gitlab::CurrentSettings.sentry_enabled? end def self.context(current_user = nil) @@ -31,7 +32,7 @@ module Gitlab def self.track_exception(exception, issue_url: nil, extra: {}) track_acceptable_exception(exception, issue_url: issue_url, extra: extra) - raise exception if should_raise? + raise exception if should_raise_for_dev? end # This should be used when you do not want to raise an exception in @@ -43,7 +44,11 @@ module Gitlab extra[:issue_url] = issue_url if issue_url context # Make sure we've set everything we know in the context - Raven.capture_exception(exception, extra: extra) + tags = { + Gitlab::CorrelationId::LOG_KEY.to_sym => Gitlab::CorrelationId.current_id + } + + Raven.capture_exception(exception, tags: tags, extra: extra) end end @@ -55,7 +60,7 @@ module Gitlab end end - def self.should_raise? + def self.should_raise_for_dev? Rails.env.development? || Rails.env.test? end end diff --git a/lib/gitlab/sidekiq_middleware/correlation_injector.rb b/lib/gitlab/sidekiq_middleware/correlation_injector.rb new file mode 100644 index 00000000000..b807b3a03ed --- /dev/null +++ b/lib/gitlab/sidekiq_middleware/correlation_injector.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Gitlab + module SidekiqMiddleware + class CorrelationInjector + def call(worker_class, job, queue, redis_pool) + job[Gitlab::CorrelationId::LOG_KEY] ||= + Gitlab::CorrelationId.current_or_new_id + + yield + end + end + end +end diff --git a/lib/gitlab/sidekiq_middleware/correlation_logger.rb b/lib/gitlab/sidekiq_middleware/correlation_logger.rb new file mode 100644 index 00000000000..cb8ff4a6284 --- /dev/null +++ b/lib/gitlab/sidekiq_middleware/correlation_logger.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Gitlab + module SidekiqMiddleware + class CorrelationLogger + def call(worker, job, queue) + correlation_id = job[Gitlab::CorrelationId::LOG_KEY] + + Gitlab::CorrelationId.use_id(correlation_id) do + yield + end + end + end + end +end diff --git a/lib/gitlab/template/finders/global_template_finder.rb b/lib/gitlab/template/finders/global_template_finder.rb index 76bb9eb611e..2dd4b7a4092 100644 --- a/lib/gitlab/template/finders/global_template_finder.rb +++ b/lib/gitlab/template/finders/global_template_finder.rb @@ -18,6 +18,10 @@ module Gitlab def find(key) file_name = "#{key}#{@extension}" + # The key is untrusted input, so ensure we can't be directed outside + # of base_dir + Gitlab::Utils.check_path_traversal!(file_name) + directory = select_directory(file_name) directory ? File.join(category_directory(directory), file_name) : nil end diff --git a/lib/gitlab/template/finders/repo_template_finder.rb b/lib/gitlab/template/finders/repo_template_finder.rb index b92cefefb8f..8e234148a63 100644 --- a/lib/gitlab/template/finders/repo_template_finder.rb +++ b/lib/gitlab/template/finders/repo_template_finder.rb @@ -26,6 +26,11 @@ module Gitlab def find(key) file_name = "#{key}#{@extension}" + + # The key is untrusted input, so ensure we can't be directed outside + # of base_dir inside the repository + Gitlab::Utils.check_path_traversal!(file_name) + directory = select_directory(file_name) raise FileNotFoundError if directory.nil? diff --git a/lib/gitlab/url_blocker.rb b/lib/gitlab/url_blocker.rb index 86efe8ad114..b8040f73cee 100644 --- a/lib/gitlab/url_blocker.rb +++ b/lib/gitlab/url_blocker.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'resolv' +require 'ipaddress' module Gitlab class UrlBlocker @@ -10,11 +11,8 @@ module Gitlab def validate!(url, allow_localhost: false, allow_local_network: true, enforce_user: false, ports: [], protocols: []) return true if url.nil? - begin - uri = Addressable::URI.parse(url) - rescue Addressable::URI::InvalidURIError - raise BlockedUrlError, "URI is invalid" - end + # Param url can be a string, URI or Addressable::URI + uri = parse_url(url) # Allow imports from the GitLab instance itself but only from the configured ports return true if internal?(uri) @@ -26,7 +24,9 @@ module Gitlab validate_hostname!(uri.hostname) begin - addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM) + addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM).map do |addr| + addr.ipv6_v4mapped? ? addr.ipv6_to_ipv4 : addr + end rescue SocketError return true end @@ -49,6 +49,18 @@ module Gitlab private + def parse_url(url) + raise Addressable::URI::InvalidURIError if multiline?(url) + + Addressable::URI.parse(url) + rescue Addressable::URI::InvalidURIError, URI::InvalidURIError + raise BlockedUrlError, 'URI is invalid' + end + + def multiline?(url) + CGI.unescape(url.to_s) =~ /\n|\r/ + end + def validate_port!(port, ports) return if port.blank? # Only ports under 1024 are restricted @@ -73,13 +85,14 @@ module Gitlab def validate_hostname!(value) return if value.blank? + return if IPAddress.valid?(value) return if value =~ /\A\p{Alnum}/ - raise BlockedUrlError, "Hostname needs to start with an alphanumeric character" + raise BlockedUrlError, "Hostname or IP address invalid" end def validate_localhost!(addrs_info) - local_ips = ["127.0.0.1", "::1", "0.0.0.0"] + local_ips = ["::", "0.0.0.0"] local_ips.concat(Socket.ip_address_list.map(&:ip_address)) return if (local_ips & addrs_info.map(&:ip_address)).empty? @@ -94,7 +107,7 @@ module Gitlab end def validate_local_network!(addrs_info) - return unless addrs_info.any? { |addr| addr.ipv4_private? || addr.ipv6_sitelocal? } + return unless addrs_info.any? { |addr| addr.ipv4_private? || addr.ipv6_sitelocal? || addr.ipv6_unique_local? } raise BlockedUrlError, "Requests to the local network are not allowed" end @@ -111,12 +124,14 @@ module Gitlab end def internal_web?(uri) - uri.hostname == config.gitlab.host && + uri.scheme == config.gitlab.protocol && + uri.hostname == config.gitlab.host && (uri.port.blank? || uri.port == config.gitlab.port) end def internal_shell?(uri) - uri.hostname == config.gitlab_shell.ssh_host && + uri.scheme == 'ssh' && + uri.hostname == config.gitlab_shell.ssh_host && (uri.port.blank? || uri.port == config.gitlab_shell.ssh_port) end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 9bceec749fc..008e9cd1d24 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -2,6 +2,8 @@ module Gitlab class UsageData + APPROXIMATE_COUNT_MODELS = [Label, MergeRequest, Note, Todo].freeze + class << self def data(force_refresh: false) Rails.cache.fetch('usage_data', force: force_refresh, expires_in: 2.weeks) { uncached_data } @@ -55,7 +57,11 @@ module Gitlab environments: count(::Environment), clusters: count(::Clusters::Cluster), clusters_enabled: count(::Clusters::Cluster.enabled), + project_clusters_enabled: count(::Clusters::Cluster.enabled.project_type), + group_clusters_enabled: count(::Clusters::Cluster.enabled.group_type), clusters_disabled: count(::Clusters::Cluster.disabled), + project_clusters_disabled: count(::Clusters::Cluster.disabled.project_type), + group_clusters_disabled: count(::Clusters::Cluster.disabled.group_type), clusters_platforms_gke: count(::Clusters::Cluster.gcp_installed.enabled), clusters_platforms_user: count(::Clusters::Cluster.user_provided.enabled), clusters_applications_helm: count(::Clusters::Applications::Helm.installed), @@ -69,12 +75,9 @@ module Gitlab issues: count(Issue), keys: count(Key), label_lists: count(List.label), - labels: count(Label), lfs_objects: count(LfsObject), - merge_requests: count(MergeRequest), milestone_lists: count(List.milestone), milestones: count(Milestone), - notes: count(Note), pages_domains: count(PagesDomain), projects: count(Project), projects_imported_from_github: count(Project.where(import_type: 'github')), @@ -82,10 +85,9 @@ module Gitlab releases: count(Release), remote_mirrors: count(RemoteMirror), snippets: count(Snippet), - todos: count(Todo), uploads: count(Upload), web_hooks: count(WebHook) - }.merge(services_usage) + }.merge(services_usage).merge(approximate_counts) } end # rubocop: enable CodeReuse/ActiveRecord @@ -160,6 +162,16 @@ module Gitlab fallback end # rubocop: enable CodeReuse/ActiveRecord + + def approximate_counts + approx_counts = Gitlab::Database::Count.approximate_counts(APPROXIMATE_COUNT_MODELS) + + APPROXIMATE_COUNT_MODELS.each_with_object({}) do |model, result| + key = model.name.underscore.pluralize.to_sym + + result[key] = approx_counts[model] || -1 + end + end end end end diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb index 9e59137a2c0..26fc56227a2 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -4,6 +4,15 @@ module Gitlab module Utils extend self + # Ensure that the relative path will not traverse outside the base directory + def check_path_traversal!(path) + raise StandardError.new("Invalid path") if path.start_with?("..#{File::SEPARATOR}") || + path.include?("#{File::SEPARATOR}..#{File::SEPARATOR}") || + path.end_with?("#{File::SEPARATOR}..") + + path + end + # Run system command without outputting to stdout. # # @param cmd [Array<String>] @@ -16,6 +25,21 @@ module Gitlab str.force_encoding(Encoding::UTF_8) end + def ensure_utf8_size(str, bytes:) + raise ArgumentError, 'Empty string provided!' if str.empty? + raise ArgumentError, 'Negative string size provided!' if bytes.negative? + + truncated = str.each_char.each_with_object(+'') do |char, object| + if object.bytesize + char.bytesize > bytes + break object + else + object.concat(char) + end + end + + truncated + ('0' * (bytes - truncated.bytesize)) + end + # Append path to host, making sure there's one single / in between def append_path(host, path) "#{host.to_s.sub(%r{\/+$}, '')}/#{path.to_s.sub(%r{^\/+}, '')}" diff --git a/lib/gitlab/wiki_file_finder.rb b/lib/gitlab/wiki_file_finder.rb index a00cd65594c..5303b3582ab 100644 --- a/lib/gitlab/wiki_file_finder.rb +++ b/lib/gitlab/wiki_file_finder.rb @@ -2,6 +2,8 @@ module Gitlab class WikiFileFinder < FileFinder + BATCH_SIZE = 100 + attr_reader :repository def initialize(project, ref) @@ -12,13 +14,11 @@ module Gitlab private - def search_filenames(query, except) + def search_filenames(query) safe_query = Regexp.escape(query.tr(' ', '-')) safe_query = Regexp.new(safe_query, Regexp::IGNORECASE) filenames = repository.ls_files(ref) - filenames.delete_if { |filename| except.include?(filename) } unless except.empty? - filenames.grep(safe_query).first(BATCH_SIZE) end end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index e1f777e9cd1..da22ea9cf5c 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -13,6 +13,7 @@ module Gitlab INTERNAL_API_REQUEST_HEADER = 'Gitlab-Workhorse-Api-Request'.freeze NOTIFICATION_CHANNEL = 'workhorse:notifications'.freeze ALLOWED_GIT_HTTP_ACTIONS = %w[git_receive_pack git_upload_pack info_refs].freeze + DETECT_HEADER = 'Gitlab-Workhorse-Detect-Content-Type'.freeze # Supposedly the effective key size for HMAC-SHA256 is 256 bits, i.e. 32 # bytes https://tools.ietf.org/html/rfc4868#section-2.6 diff --git a/lib/omni_auth/strategies/jwt.rb b/lib/omni_auth/strategies/jwt.rb index a792903fde7..2f3d477a591 100644 --- a/lib/omni_auth/strategies/jwt.rb +++ b/lib/omni_auth/strategies/jwt.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'omniauth' +require 'openssl' require 'jwt' module OmniAuth @@ -37,7 +38,19 @@ module OmniAuth end def decoded - @decoded ||= ::JWT.decode(request.params['jwt'], options.secret, options.algorithm).first + secret = + case options.algorithm + when *%w[RS256 RS384 RS512] + OpenSSL::PKey::RSA.new(options.secret).public_key + when *%w[ES256 ES384 ES512] + OpenSSL::PKey::EC.new(options.secret).tap { |key| key.private_key = nil } + when *%w(HS256 HS384 HS512) + options.secret + else + raise NotImplementedError, "Unsupported algorithm: #{options.algorithm}" + end + + @decoded ||= ::JWT.decode(request.params['jwt'], secret, true, { algorithm: options.algorithm }).first (options.required_claims || []).each do |field| raise ClaimInvalid, "Missing required '#{field}' claim" unless @decoded.key?(field.to_s) @@ -45,7 +58,7 @@ module OmniAuth raise ClaimInvalid, "Missing required 'iat' claim" if options.valid_within && !@decoded["iat"] - if options.valid_within && (Time.now.to_i - @decoded["iat"]).abs > options.valid_within + if options.valid_within && (Time.now.to_i - @decoded["iat"]).abs > options.valid_within.to_i raise ClaimInvalid, "'iat' timestamp claim is too skewed from present" end diff --git a/lib/system_check/gitaly_check.rb b/lib/system_check/gitaly_check.rb new file mode 100644 index 00000000000..3d2517a7aca --- /dev/null +++ b/lib/system_check/gitaly_check.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SystemCheck + class GitalyCheck < BaseCheck + set_name 'Gitaly:' + + def multi_check + Gitlab::HealthChecks::GitalyCheck.readiness.each do |result| + $stdout.print "#{result.labels[:shard]} ... " + + if result.success + $stdout.puts 'OK'.color(:green) + else + $stdout.puts "FAIL: #{result.message}".color(:red) + end + end + end + end +end diff --git a/lib/system_check/gitlab_shell_check.rb b/lib/system_check/gitlab_shell_check.rb new file mode 100644 index 00000000000..31c4ec33247 --- /dev/null +++ b/lib/system_check/gitlab_shell_check.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module SystemCheck + # Used by gitlab:gitlab_shell:check rake task + class GitlabShellCheck < BaseCheck + set_name 'GitLab Shell:' + + def multi_check + check_gitlab_shell + check_gitlab_shell_self_test + end + + private + + def check_gitlab_shell + required_version = Gitlab::VersionInfo.parse(Gitlab::Shell.version_required) + current_version = Gitlab::VersionInfo.parse(gitlab_shell_version) + + $stdout.print "GitLab Shell version >= #{required_version} ? ... " + if current_version.valid? && required_version <= current_version + $stdout.puts "OK (#{current_version})".color(:green) + else + $stdout.puts "FAIL. Please update gitlab-shell to #{required_version} from #{current_version}".color(:red) + end + end + + def check_gitlab_shell_self_test + gitlab_shell_repo_base = gitlab_shell_path + check_cmd = File.expand_path('bin/check', gitlab_shell_repo_base) + $stdout.puts "Running #{check_cmd}" + + if system(check_cmd, chdir: gitlab_shell_repo_base) + $stdout.puts 'gitlab-shell self-check successful'.color(:green) + else + $stdout.puts 'gitlab-shell self-check failed'.color(:red) + try_fixing_it( + 'Make sure GitLab is running;', + 'Check the gitlab-shell configuration file:', + sudo_gitlab("editor #{File.expand_path('config.yml', gitlab_shell_repo_base)}") + ) + fix_and_rerun + end + end + + # Helper methods + ######################## + + def gitlab_shell_path + Gitlab.config.gitlab_shell.path + end + + def gitlab_shell_version + Gitlab::Shell.new.version + end + end +end diff --git a/lib/system_check/incoming_email_check.rb b/lib/system_check/incoming_email_check.rb new file mode 100644 index 00000000000..155b6547595 --- /dev/null +++ b/lib/system_check/incoming_email_check.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module SystemCheck + # Used by gitlab:incoming_email:check rake task + class IncomingEmailCheck < BaseCheck + set_name 'Incoming Email:' + + def multi_check + if Gitlab.config.incoming_email.enabled + checks = [ + SystemCheck::IncomingEmail::ImapAuthenticationCheck + ] + + if Rails.env.production? + checks << SystemCheck::IncomingEmail::InitdConfiguredCheck + checks << SystemCheck::IncomingEmail::MailRoomRunningCheck + else + checks << SystemCheck::IncomingEmail::ForemanConfiguredCheck + end + + SystemCheck.run('Reply by email', checks) + else + $stdout.puts 'Reply by email is disabled in config/gitlab.yml' + end + end + end +end diff --git a/lib/system_check/ldap_check.rb b/lib/system_check/ldap_check.rb new file mode 100644 index 00000000000..619fb3cccb8 --- /dev/null +++ b/lib/system_check/ldap_check.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module SystemCheck + # Used by gitlab:ldap:check rake task + class LdapCheck < BaseCheck + set_name 'LDAP:' + + def multi_check + if Gitlab::Auth::LDAP::Config.enabled? + # Only show up to 100 results because LDAP directories can be very big. + # This setting only affects the `rake gitlab:check` script. + limit = ENV['LDAP_CHECK_LIMIT'] + limit = 100 if limit.blank? + + check_ldap(limit) + else + $stdout.puts 'LDAP is disabled in config/gitlab.yml' + end + end + + private + + def check_ldap(limit) + servers = Gitlab::Auth::LDAP::Config.providers + + servers.each do |server| + $stdout.puts "Server: #{server}" + + begin + Gitlab::Auth::LDAP::Adapter.open(server) do |adapter| + check_ldap_auth(adapter) + + $stdout.puts "LDAP users with access to your GitLab server (only showing the first #{limit} results)" + + users = adapter.users(adapter.config.uid, '*', limit) + users.each do |user| + $stdout.puts "\tDN: #{user.dn}\t #{adapter.config.uid}: #{user.uid}" + end + end + rescue Net::LDAP::ConnectionRefusedError, Errno::ECONNREFUSED => e + $stdout.puts "Could not connect to the LDAP server: #{e.message}".color(:red) + end + end + end + + def check_ldap_auth(adapter) + auth = adapter.config.has_auth? + + message = if auth && adapter.ldap.bind + 'Success'.color(:green) + elsif auth + 'Failed. Check `bind_dn` and `password` configuration values'.color(:red) + else + 'Anonymous. No `bind_dn` or `password` configured'.color(:yellow) + end + + $stdout.puts "LDAP authentication... #{message}" + end + end +end diff --git a/lib/system_check/orphans/repository_check.rb b/lib/system_check/orphans/repository_check.rb index ef8fe945f61..33020417e95 100644 --- a/lib/system_check/orphans/repository_check.rb +++ b/lib/system_check/orphans/repository_check.rb @@ -4,7 +4,6 @@ module SystemCheck module Orphans class RepositoryCheck < SystemCheck::BaseCheck set_name 'Orphaned repositories:' - attr_accessor :orphans def multi_check Gitlab::GitalyClient::StorageSettings.allow_disk_access do diff --git a/lib/system_check/rake_task/app_task.rb b/lib/system_check/rake_task/app_task.rb new file mode 100644 index 00000000000..cc32feb8604 --- /dev/null +++ b/lib/system_check/rake_task/app_task.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + # Used by gitlab:app:check rake task + module AppTask + extend RakeTaskHelpers + + def self.name + 'GitLab App' + end + + def self.checks + [ + SystemCheck::App::GitConfigCheck, + SystemCheck::App::DatabaseConfigExistsCheck, + SystemCheck::App::MigrationsAreUpCheck, + SystemCheck::App::OrphanedGroupMembersCheck, + SystemCheck::App::GitlabConfigExistsCheck, + SystemCheck::App::GitlabConfigUpToDateCheck, + SystemCheck::App::LogWritableCheck, + SystemCheck::App::TmpWritableCheck, + SystemCheck::App::UploadsDirectoryExistsCheck, + SystemCheck::App::UploadsPathPermissionCheck, + SystemCheck::App::UploadsPathTmpPermissionCheck, + SystemCheck::App::InitScriptExistsCheck, + SystemCheck::App::InitScriptUpToDateCheck, + SystemCheck::App::ProjectsHaveNamespaceCheck, + SystemCheck::App::RedisVersionCheck, + SystemCheck::App::RubyVersionCheck, + SystemCheck::App::GitVersionCheck, + SystemCheck::App::GitUserDefaultSSHConfigCheck, + SystemCheck::App::ActiveUsersCheck + ] + end + end + end +end diff --git a/lib/system_check/rake_task/gitaly_task.rb b/lib/system_check/rake_task/gitaly_task.rb new file mode 100644 index 00000000000..0c3f694f98a --- /dev/null +++ b/lib/system_check/rake_task/gitaly_task.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + # Used by gitlab:gitaly:check rake task + class GitalyTask + extend RakeTaskHelpers + + def self.name + 'Gitaly' + end + + def self.checks + [SystemCheck::GitalyCheck] + end + end + end +end diff --git a/lib/system_check/rake_task/gitlab_shell_task.rb b/lib/system_check/rake_task/gitlab_shell_task.rb new file mode 100644 index 00000000000..120e984c68b --- /dev/null +++ b/lib/system_check/rake_task/gitlab_shell_task.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + # Used by gitlab:gitlab_shell:check rake task + class GitlabShellTask + extend RakeTaskHelpers + + def self.name + 'GitLab Shell' + end + + def self.checks + [SystemCheck::GitlabShellCheck] + end + end + end +end diff --git a/lib/system_check/rake_task/gitlab_task.rb b/lib/system_check/rake_task/gitlab_task.rb new file mode 100644 index 00000000000..7ff36fd6eb5 --- /dev/null +++ b/lib/system_check/rake_task/gitlab_task.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + # Used by gitlab:check rake task + class GitlabTask + extend RakeTaskHelpers + + def self.name + 'GitLab' + end + + def self.manual_run_checks! + start_checking("#{name} subtasks") + + subtasks.each(&:run_checks!) + + finished_checking("#{name} subtasks") + end + + def self.subtasks + [ + SystemCheck::RakeTask::GitlabShellTask, + SystemCheck::RakeTask::GitalyTask, + SystemCheck::RakeTask::SidekiqTask, + SystemCheck::RakeTask::IncomingEmailTask, + SystemCheck::RakeTask::LdapTask, + SystemCheck::RakeTask::AppTask + ] + end + end + end +end diff --git a/lib/system_check/rake_task/incoming_email_task.rb b/lib/system_check/rake_task/incoming_email_task.rb new file mode 100644 index 00000000000..c296c46feab --- /dev/null +++ b/lib/system_check/rake_task/incoming_email_task.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + # Used by gitlab:incoming_email:check rake task + class IncomingEmailTask + extend RakeTaskHelpers + + def self.name + 'Incoming Email' + end + + def self.checks + [SystemCheck::IncomingEmailCheck] + end + end + end +end diff --git a/lib/system_check/rake_task/ldap_task.rb b/lib/system_check/rake_task/ldap_task.rb new file mode 100644 index 00000000000..03a180b9dfb --- /dev/null +++ b/lib/system_check/rake_task/ldap_task.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + # Used by gitlab:ldap:check rake task + class LdapTask + extend RakeTaskHelpers + + def self.name + 'LDAP' + end + + def self.checks + [SystemCheck::LdapCheck] + end + end + end +end diff --git a/lib/system_check/rake_task/orphans/namespace_task.rb b/lib/system_check/rake_task/orphans/namespace_task.rb new file mode 100644 index 00000000000..2822da45bc1 --- /dev/null +++ b/lib/system_check/rake_task/orphans/namespace_task.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + module Orphans + # Used by gitlab:orphans:check_namespaces rake task + class NamespaceTask + extend RakeTaskHelpers + + def self.name + 'Orphans' + end + + def self.checks + [SystemCheck::Orphans::NamespaceCheck] + end + end + end + end +end diff --git a/lib/system_check/rake_task/orphans/repository_task.rb b/lib/system_check/rake_task/orphans/repository_task.rb new file mode 100644 index 00000000000..f14b3af22e8 --- /dev/null +++ b/lib/system_check/rake_task/orphans/repository_task.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + module Orphans + # Used by gitlab:orphans:check_repositories rake task + class RepositoryTask + extend RakeTaskHelpers + + def self.name + 'Orphans' + end + + def self.checks + [SystemCheck::Orphans::RepositoryCheck] + end + end + end + end +end diff --git a/lib/system_check/rake_task/orphans_task.rb b/lib/system_check/rake_task/orphans_task.rb new file mode 100644 index 00000000000..31f8ede25e0 --- /dev/null +++ b/lib/system_check/rake_task/orphans_task.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + # Used by gitlab:orphans:check rake task + class OrphansTask + extend RakeTaskHelpers + + def self.name + 'Orphans' + end + + def self.checks + [ + SystemCheck::Orphans::NamespaceCheck, + SystemCheck::Orphans::RepositoryCheck + ] + end + end + end +end diff --git a/lib/system_check/rake_task/rake_task_helpers.rb b/lib/system_check/rake_task/rake_task_helpers.rb new file mode 100644 index 00000000000..95f2a34a719 --- /dev/null +++ b/lib/system_check/rake_task/rake_task_helpers.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + # Provides the run! method intended to be called from system check rake tasks + module RakeTaskHelpers + include ::SystemCheck::Helpers + + def run! + warn_user_is_not_gitlab + + if self.respond_to?(:manual_run_checks!) + manual_run_checks! + else + run_checks! + end + end + + def run_checks! + SystemCheck.run(name, checks) + end + + def name + raise NotImplementedError + end + + def checks + raise NotImplementedError + end + end + end +end diff --git a/lib/system_check/rake_task/sidekiq_task.rb b/lib/system_check/rake_task/sidekiq_task.rb new file mode 100644 index 00000000000..3ccf009d4b9 --- /dev/null +++ b/lib/system_check/rake_task/sidekiq_task.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module SystemCheck + module RakeTask + # Used by gitlab:sidekiq:check rake task + class SidekiqTask + extend RakeTaskHelpers + + def self.name + 'Sidekiq' + end + + def self.checks + [SystemCheck::SidekiqCheck] + end + end + end +end diff --git a/lib/system_check/sidekiq_check.rb b/lib/system_check/sidekiq_check.rb new file mode 100644 index 00000000000..2f5fc2cea89 --- /dev/null +++ b/lib/system_check/sidekiq_check.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module SystemCheck + # Used by gitlab:sidekiq:check rake task + class SidekiqCheck < BaseCheck + set_name 'Sidekiq:' + + def multi_check + check_sidekiq_running + only_one_sidekiq_running + end + + private + + def check_sidekiq_running + $stdout.print "Running? ... " + + if sidekiq_process_count > 0 + $stdout.puts "yes".color(:green) + else + $stdout.puts "no".color(:red) + try_fixing_it( + sudo_gitlab("RAILS_ENV=production bin/background_jobs start") + ) + for_more_information( + see_installation_guide_section("Install Init Script"), + "see log/sidekiq.log for possible errors" + ) + fix_and_rerun + end + end + + def only_one_sidekiq_running + process_count = sidekiq_process_count + return if process_count.zero? + + $stdout.print 'Number of Sidekiq processes ... ' + + if process_count == 1 + $stdout.puts '1'.color(:green) + else + $stdout.puts "#{process_count}".color(:red) + try_fixing_it( + 'sudo service gitlab stop', + "sudo pkill -u #{gitlab_user} -f sidekiq", + "sleep 10 && sudo pkill -9 -u #{gitlab_user} -f sidekiq", + 'sudo service gitlab start' + ) + fix_and_rerun + end + end + + def sidekiq_process_count + ps_ux, _ = Gitlab::Popen.popen(%w(ps uxww)) + ps_ux.scan(/sidekiq \d+\.\d+\.\d+/).count + end + end +end diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake index a497d26312e..2235a6ba194 100644 --- a/lib/tasks/gettext.rake +++ b/lib/tasks/gettext.rake @@ -82,7 +82,7 @@ namespace :gettext do # `gettext:find` writes touches to temp files to `stderr` which would cause # `static-analysis` to report failures. We can ignore these. - silence_sdterr do + silence_stderr do Rake::Task['gettext:find'].invoke end @@ -119,7 +119,7 @@ namespace :gettext do end end - def silence_sdterr(&block) + def silence_stderr(&block) old_stderr = $stderr.dup $stderr.reopen(File::NULL) $stderr.sync = true diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake index a2c3e32948f..b594f150c3b 100644 --- a/lib/tasks/gitlab/check.rake +++ b/lib/tasks/gitlab/check.rake @@ -1,299 +1,66 @@ namespace :gitlab do desc 'GitLab | Check the configuration of GitLab and its environment' - task check: %w{gitlab:gitlab_shell:check - gitlab:gitaly:check - gitlab:sidekiq:check - gitlab:incoming_email:check - gitlab:ldap:check - gitlab:app:check} + task check: :gitlab_environment do + SystemCheck::RakeTask::GitlabTask.run! + end namespace :app do desc 'GitLab | Check the configuration of the GitLab Rails app' task check: :gitlab_environment do - warn_user_is_not_gitlab - - checks = [ - SystemCheck::App::GitConfigCheck, - SystemCheck::App::DatabaseConfigExistsCheck, - SystemCheck::App::MigrationsAreUpCheck, - SystemCheck::App::OrphanedGroupMembersCheck, - SystemCheck::App::GitlabConfigExistsCheck, - SystemCheck::App::GitlabConfigUpToDateCheck, - SystemCheck::App::LogWritableCheck, - SystemCheck::App::TmpWritableCheck, - SystemCheck::App::UploadsDirectoryExistsCheck, - SystemCheck::App::UploadsPathPermissionCheck, - SystemCheck::App::UploadsPathTmpPermissionCheck, - SystemCheck::App::InitScriptExistsCheck, - SystemCheck::App::InitScriptUpToDateCheck, - SystemCheck::App::ProjectsHaveNamespaceCheck, - SystemCheck::App::RedisVersionCheck, - SystemCheck::App::RubyVersionCheck, - SystemCheck::App::GitVersionCheck, - SystemCheck::App::GitUserDefaultSSHConfigCheck, - SystemCheck::App::ActiveUsersCheck - ] - - SystemCheck.run('GitLab', checks) + SystemCheck::RakeTask::AppTask.run! end end namespace :gitlab_shell do desc "GitLab | Check the configuration of GitLab Shell" task check: :gitlab_environment do - warn_user_is_not_gitlab - start_checking "GitLab Shell" - - check_gitlab_shell - check_gitlab_shell_self_test - - finished_checking "GitLab Shell" - end - - # Checks - ######################## - - def check_gitlab_shell_self_test - gitlab_shell_repo_base = gitlab_shell_path - check_cmd = File.expand_path('bin/check', gitlab_shell_repo_base) - puts "Running #{check_cmd}" - - if system(check_cmd, chdir: gitlab_shell_repo_base) - puts 'gitlab-shell self-check successful'.color(:green) - else - puts 'gitlab-shell self-check failed'.color(:red) - try_fixing_it( - 'Make sure GitLab is running;', - 'Check the gitlab-shell configuration file:', - sudo_gitlab("editor #{File.expand_path('config.yml', gitlab_shell_repo_base)}") - ) - fix_and_rerun - end - end - - # Helper methods - ######################## - - def gitlab_shell_path - Gitlab.config.gitlab_shell.path - end - - def gitlab_shell_version - Gitlab::Shell.new.version - end - - def gitlab_shell_major_version - Gitlab::Shell.version_required.split('.')[0].to_i - end - - def gitlab_shell_minor_version - Gitlab::Shell.version_required.split('.')[1].to_i - end - - def gitlab_shell_patch_version - Gitlab::Shell.version_required.split('.')[2].to_i + SystemCheck::RakeTask::GitlabShellTask.run! end end namespace :gitaly do desc 'GitLab | Check the health of Gitaly' task check: :gitlab_environment do - warn_user_is_not_gitlab - start_checking 'Gitaly' - - Gitlab::HealthChecks::GitalyCheck.readiness.each do |result| - print "#{result.labels[:shard]} ... " - - if result.success - puts 'OK'.color(:green) - else - puts "FAIL: #{result.message}".color(:red) - end - end - - finished_checking 'Gitaly' + SystemCheck::RakeTask::GitalyTask.run! end end namespace :sidekiq do desc "GitLab | Check the configuration of Sidekiq" task check: :gitlab_environment do - warn_user_is_not_gitlab - start_checking "Sidekiq" - - check_sidekiq_running - only_one_sidekiq_running - - finished_checking "Sidekiq" - end - - # Checks - ######################## - - def check_sidekiq_running - print "Running? ... " - - if sidekiq_process_count > 0 - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - sudo_gitlab("RAILS_ENV=production bin/background_jobs start") - ) - for_more_information( - see_installation_guide_section("Install Init Script"), - "see log/sidekiq.log for possible errors" - ) - fix_and_rerun - end - end - - def only_one_sidekiq_running - process_count = sidekiq_process_count - return if process_count.zero? - - print 'Number of Sidekiq processes ... ' - - if process_count == 1 - puts '1'.color(:green) - else - puts "#{process_count}".color(:red) - try_fixing_it( - 'sudo service gitlab stop', - "sudo pkill -u #{gitlab_user} -f sidekiq", - "sleep 10 && sudo pkill -9 -u #{gitlab_user} -f sidekiq", - 'sudo service gitlab start' - ) - fix_and_rerun - end - end - - def sidekiq_process_count - ps_ux, _ = Gitlab::Popen.popen(%w(ps uxww)) - ps_ux.scan(/sidekiq \d+\.\d+\.\d+/).count + SystemCheck::RakeTask::SidekiqTask.run! end end namespace :incoming_email do desc "GitLab | Check the configuration of Reply by email" task check: :gitlab_environment do - warn_user_is_not_gitlab - - if Gitlab.config.incoming_email.enabled - checks = [ - SystemCheck::IncomingEmail::ImapAuthenticationCheck - ] - - if Rails.env.production? - checks << SystemCheck::IncomingEmail::InitdConfiguredCheck - checks << SystemCheck::IncomingEmail::MailRoomRunningCheck - else - checks << SystemCheck::IncomingEmail::ForemanConfiguredCheck - end - - SystemCheck.run('Reply by email', checks) - else - puts 'Reply by email is disabled in config/gitlab.yml' - end + SystemCheck::RakeTask::IncomingEmailTask.run! end end namespace :ldap do task :check, [:limit] => :gitlab_environment do |_, args| - # Only show up to 100 results because LDAP directories can be very big. - # This setting only affects the `rake gitlab:check` script. - args.with_defaults(limit: 100) - warn_user_is_not_gitlab - start_checking "LDAP" - - if Gitlab::Auth::LDAP::Config.enabled? - check_ldap(args.limit) - else - puts 'LDAP is disabled in config/gitlab.yml' - end - - finished_checking "LDAP" - end - - def check_ldap(limit) - servers = Gitlab::Auth::LDAP::Config.providers - - servers.each do |server| - puts "Server: #{server}" + ENV['LDAP_CHECK_LIMIT'] = args.limit if args.limit.present? - begin - Gitlab::Auth::LDAP::Adapter.open(server) do |adapter| - check_ldap_auth(adapter) - - puts "LDAP users with access to your GitLab server (only showing the first #{limit} results)" - - users = adapter.users(adapter.config.uid, '*', limit) - users.each do |user| - puts "\tDN: #{user.dn}\t #{adapter.config.uid}: #{user.uid}" - end - end - rescue Net::LDAP::ConnectionRefusedError, Errno::ECONNREFUSED => e - puts "Could not connect to the LDAP server: #{e.message}".color(:red) - end - end - end - - def check_ldap_auth(adapter) - auth = adapter.config.has_auth? - - message = if auth && adapter.ldap.bind - 'Success'.color(:green) - elsif auth - 'Failed. Check `bind_dn` and `password` configuration values'.color(:red) - else - 'Anonymous. No `bind_dn` or `password` configured'.color(:yellow) - end - - puts "LDAP authentication... #{message}" + SystemCheck::RakeTask::LdapTask.run! end end namespace :orphans do desc 'Gitlab | Check for orphaned namespaces and repositories' task check: :gitlab_environment do - warn_user_is_not_gitlab - checks = [ - SystemCheck::Orphans::NamespaceCheck, - SystemCheck::Orphans::RepositoryCheck - ] - - SystemCheck.run('Orphans', checks) + SystemCheck::RakeTask::OrphansTask.run! end desc 'GitLab | Check for orphaned namespaces in the repositories path' task check_namespaces: :gitlab_environment do - warn_user_is_not_gitlab - checks = [SystemCheck::Orphans::NamespaceCheck] - - SystemCheck.run('Orphans', checks) + SystemCheck::RakeTask::Orphans::NamespaceTask.run! end desc 'GitLab | Check for orphaned repositories in the repositories path' task check_repositories: :gitlab_environment do - warn_user_is_not_gitlab - checks = [SystemCheck::Orphans::RepositoryCheck] - - SystemCheck.run('Orphans', checks) - end - end - - # Helper methods - ########################## - - def check_gitlab_shell - required_version = Gitlab::VersionInfo.new(gitlab_shell_major_version, gitlab_shell_minor_version, gitlab_shell_patch_version) - current_version = Gitlab::VersionInfo.parse(gitlab_shell_version) - - print "GitLab Shell version >= #{required_version} ? ... " - if current_version.valid? && required_version <= current_version - puts "OK (#{current_version})".color(:green) - else - puts "FAIL. Please update gitlab-shell to #{required_version} from #{current_version}".color(:red) + SystemCheck::RakeTask::Orphans::RepositoryTask.run! end end end diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake index e8ae5dfa540..451ba651674 100644 --- a/lib/tasks/gitlab/cleanup.rake +++ b/lib/tasks/gitlab/cleanup.rake @@ -6,7 +6,7 @@ namespace :gitlab do desc "GitLab | Cleanup | Clean namespaces" task dirs: :gitlab_environment do namespaces = Set.new(Namespace.pluck(:path)) - namespaces << Storage::HashedProject::ROOT_PATH_PREFIX + namespaces << Storage::HashedProject::REPOSITORY_PATH_PREFIX Gitaly::Server.all.each do |server| all_dirs = Gitlab::GitalyClient::StorageService @@ -49,7 +49,7 @@ namespace :gitlab do # TODO ignoring hashed repositories for now. But revisit to fully support # possible orphaned hashed repos - next if repo_with_namespace.start_with?(Storage::HashedProject::ROOT_PATH_PREFIX) + next if repo_with_namespace.start_with?(Storage::HashedProject::REPOSITORY_PATH_PREFIX) next if Project.find_by_full_path(repo_with_namespace) new_path = path + move_suffix diff --git a/lib/tasks/gitlab/web_hook.rake b/lib/tasks/gitlab/web_hook.rake index 5a1c8006052..15cec80b6a6 100644 --- a/lib/tasks/gitlab/web_hook.rake +++ b/lib/tasks/gitlab/web_hook.rake @@ -25,11 +25,22 @@ namespace :gitlab do web_hook_url = ENV['URL'] namespace_path = ENV['NAMESPACE'] - projects = find_projects(namespace_path) - project_ids = projects.pluck(:id) + web_hooks = find_web_hooks(namespace_path) puts "Removing webhooks with the url '#{web_hook_url}' ... " - count = WebHook.where(url: web_hook_url, project_id: project_ids, type: 'ProjectHook').delete_all + + # FIXME: Hook URLs are now encrypted, so there is no way to efficiently + # find them all in SQL. For now, check them in Ruby. If this is too slow, + # we could consider storing a hash of the URL alongside the encrypted + # value to speed up searches + count = 0 + web_hooks.find_each do |hook| + next unless hook.url == web_hook_url + + hook.destroy! + count += 1 + end + puts "#{count} webhooks were removed." end @@ -37,29 +48,37 @@ namespace :gitlab do task list: :environment do namespace_path = ENV['NAMESPACE'] - projects = find_projects(namespace_path) - web_hooks = projects.all.map(&:hooks).flatten - web_hooks.each do |hook| + web_hooks = find_web_hooks(namespace_path) + web_hooks.find_each do |hook| puts "#{hook.project.name.truncate(20).ljust(20)} -> #{hook.url}" end - puts "\n#{web_hooks.size} webhooks found." + puts "\n#{web_hooks.count} webhooks found." end end def find_projects(namespace_path) if namespace_path.blank? Project - elsif namespace_path == '/' - Project.in_namespace(nil) else - namespace = Namespace.where(path: namespace_path).first - if namespace - Project.in_namespace(namespace.id) - else + namespace = Namespace.find_by_full_path(namespace_path) + + unless namespace puts "Namespace not found: #{namespace_path}".color(:red) exit 2 end + + Project.in_namespace(namespace.id) + end + end + + def find_web_hooks(namespace_path) + if namespace_path.blank? + ProjectHook + else + project_ids = find_projects(namespace_path).select(:id) + + ProjectHook.where(project_id: project_ids) end end end diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake index a16d4c47273..f912f521dfb 100644 --- a/lib/tasks/import.rake +++ b/lib/tasks/import.rake @@ -42,7 +42,7 @@ class GithubImport end def import! - @project.force_import_start + @project.import_state.force_start import_success = false @@ -57,7 +57,7 @@ class GithubImport puts "Import finished. Timings: #{timings}".color(:green) else puts "Import was not successful. Errors were as follows:" - puts @project.import_error + puts @project.import_state.last_error end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 837768efef2..d7d03151cdf 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -141,6 +141,24 @@ msgstr "" msgid "%{percent}%% complete" msgstr "" +msgid "%{strong_start}%{branch_count}%{strong_end} Branch" +msgid_plural "%{strong_start}%{branch_count}%{strong_end} Branches" +msgstr[0] "" +msgstr[1] "" + +msgid "%{strong_start}%{commit_count}%{strong_end} Commit" +msgid_plural "%{strong_start}%{commit_count}%{strong_end} Commits" +msgstr[0] "" +msgstr[1] "" + +msgid "%{strong_start}%{human_size}%{strong_end} Files" +msgstr "" + +msgid "%{strong_start}%{tag_count}%{strong_end} Tag" +msgid_plural "%{strong_start}%{tag_count}%{strong_end} Tags" +msgstr[0] "" +msgstr[1] "" + msgid "%{text} %{files}" msgid_plural "%{text} %{files} files" msgstr[0] "" @@ -158,6 +176,9 @@ msgstr "" msgid "%{usage_ping_link_start}Learn more%{usage_ping_link_end} about what information is shared with GitLab Inc." msgstr "" +msgid "%{user_name} profile page" +msgstr "" + msgid "+ %{count} more" msgstr "" @@ -222,7 +243,7 @@ msgstr "" msgid "2FA enabled" msgstr "" -msgid "403|Please contact your GitLab administrator to get the permission." +msgid "403|Please contact your GitLab administrator to get permission." msgstr "" msgid "403|You don't have the permission to access this page." @@ -300,6 +321,9 @@ msgstr "" msgid "Abuse reports" msgstr "" +msgid "Accept invitation" +msgstr "" + msgid "Accept terms" msgstr "" @@ -327,16 +351,16 @@ msgstr "" msgid "Activity" msgstr "" -msgid "Add Changelog" +msgid "Add CHANGELOG" msgstr "" -msgid "Add Contribution guide" +msgid "Add CONTRIBUTING" msgstr "" msgid "Add Kubernetes cluster" msgstr "" -msgid "Add Readme" +msgid "Add README" msgstr "" msgid "Add a homepage to your wiki that contains information about your project and GitLab will display it here instead of this message." @@ -804,6 +828,9 @@ msgstr "" msgid "Automatically marked as default internal user" msgstr "" +msgid "Automatically resolved" +msgstr "" + msgid "Available" msgstr "" @@ -936,11 +963,6 @@ msgstr "" msgid "Branch %{branchName} was not found in this project's repository." msgstr "" -msgid "Branch (%{branch_count})" -msgid_plural "Branches (%{branch_count})" -msgstr[0] "" -msgstr[1] "" - msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}" msgstr "" @@ -1088,9 +1110,15 @@ msgstr "" msgid "Business metrics (Custom)" msgstr "" +msgid "By %{user_name}" +msgstr "" + msgid "ByAuthor|by" msgstr "" +msgid "CHANGELOG" +msgstr "" + msgid "CI / CD" msgstr "" @@ -1148,6 +1176,9 @@ msgstr "" msgid "CICD|instance enabled" msgstr "" +msgid "CONTRIBUTING" +msgstr "" + msgid "Callback URL" msgstr "" @@ -1190,9 +1221,6 @@ msgstr "" msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes." msgstr "" -msgid "Changelog" -msgstr "" - msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision." msgstr "" @@ -1232,6 +1260,9 @@ msgstr "" msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request." msgstr "" +msgid "Choose a file" +msgstr "" + msgid "Choose a template..." msgstr "" @@ -1373,9 +1404,18 @@ msgstr "" msgid "Clients" msgstr "" +msgid "Clone" +msgstr "" + msgid "Clone repository" msgstr "" +msgid "Clone with %{http_label}" +msgstr "" + +msgid "Clone with SSH" +msgstr "" + msgid "Close" msgstr "" @@ -1559,15 +1599,15 @@ msgstr "" msgid "ClusterIntegration|Knative" msgstr "" -msgid "ClusterIntegration|Knative (pronounced kay-nay-tiv) extends Kubernetes to provide a set of middleware components that are essential to build modern, source-centric, and container-based applications that can run anywhere: on premises, in the cloud, or even in a third-party data center." -msgstr "" - msgid "ClusterIntegration|Knative Domain Name:" msgstr "" msgid "ClusterIntegration|Knative IP Address:" msgstr "" +msgid "ClusterIntegration|Knative extends Kubernetes to provide a set of middleware components that are essential to build modern, source-centric, and container-based applications that can run anywhere: on premises, in the cloud, or even in a third-party data center." +msgstr "" + msgid "ClusterIntegration|Kubernetes cluster" msgstr "" @@ -1796,6 +1836,9 @@ msgstr "" msgid "Collapse sidebar" msgstr "" +msgid "Command line instructions" +msgstr "" + msgid "Comment" msgstr "" @@ -1816,11 +1859,6 @@ msgid_plural "Commits" msgstr[0] "" msgstr[1] "" -msgid "Commit (%{commit_count})" -msgid_plural "Commits (%{commit_count})" -msgstr[0] "" -msgstr[1] "" - msgid "Commit Message" msgstr "" @@ -2004,9 +2042,6 @@ msgstr "" msgid "Contribution Charts" msgstr "" -msgid "Contribution guide" -msgstr "" - msgid "Contributions for <strong>%{calendar_date}</strong>" msgstr "" @@ -2031,10 +2066,10 @@ msgstr "" msgid "ConvDev Index" msgstr "" -msgid "Copy %{protocol} clone URL" +msgid "Copy %{http_label} clone URL" msgstr "" -msgid "Copy HTTPS clone URL" +msgid "Copy %{protocol} clone URL" msgstr "" msgid "Copy ID to clipboard" @@ -2097,6 +2132,9 @@ msgstr "" msgid "Create a new issue" msgstr "" +msgid "Create a new repository" +msgstr "" + msgid "Create a personal access token on your account to pull or push via %{protocol}." msgstr "" @@ -2253,6 +2291,9 @@ msgstr "" msgid "December" msgstr "" +msgid "Decline" +msgstr "" + msgid "Decline and sign out" msgstr "" @@ -2579,6 +2620,9 @@ msgstr "" msgid "Edit application" msgstr "" +msgid "Edit comment" +msgstr "" + msgid "Edit environment" msgstr "" @@ -2831,6 +2875,9 @@ msgstr "" msgid "EventFilterBy|Filter by team" msgstr "" +msgid "Every %{action} attempt has failed: %{job_error_message}. Please try again." +msgstr "" + msgid "Every day (at 4:00am)" msgstr "" @@ -2846,6 +2893,12 @@ msgstr "" msgid "Everyone can contribute" msgstr "" +msgid "Existing Git repository" +msgstr "" + +msgid "Existing folder" +msgstr "" + msgid "Expand" msgstr "" @@ -2918,6 +2971,9 @@ msgstr "" msgid "Failed to update issues, please try again." msgstr "" +msgid "Failed to upload object map file" +msgstr "" + msgid "Failure" msgstr "" @@ -2951,9 +3007,6 @@ msgstr "" msgid "Files" msgstr "" -msgid "Files (%{human_size})" -msgstr "" - msgid "Filter" msgstr "" @@ -3080,6 +3133,9 @@ msgstr "" msgid "Git" msgstr "" +msgid "Git global setup" +msgstr "" + msgid "Git repository URL" msgstr "" @@ -3361,6 +3417,9 @@ msgstr "" msgid "Housekeeping successfully started" msgstr "" +msgid "However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation." +msgstr "" + msgid "I accept the %{terms_link}" msgstr "" @@ -3415,6 +3474,9 @@ msgstr "" msgid "Identities" msgstr "" +msgid "If any job surpasses this timeout threshold, it will be marked as failed. Human readable time input language is accepted like \"1 hour\". Values without specification represent seconds." +msgstr "" + msgid "If disabled, the access level will depend on the user's permissions in the project." msgstr "" @@ -3436,6 +3498,9 @@ msgstr "" msgid "ImageDiffViewer|Swipe" msgstr "" +msgid "Impersonation has been disabled" +msgstr "" + msgid "Import" msgstr "" @@ -3487,6 +3552,9 @@ msgstr "" msgid "Import repository" msgstr "" +msgid "Import timed out. Import took longer than %{import_jobs_expiration} seconds" +msgstr "" + msgid "In order to enable instance-level analytics, please ask an admin to enable %{usage_ping_link_start}usage ping%{usage_ping_link_end}." msgstr "" @@ -3538,6 +3606,9 @@ msgstr "" msgid "Interested parties can even contribute by pushing commits if they want to." msgstr "" +msgid "Internal" +msgstr "" + msgid "Internal - The group and any internal projects can be viewed by any logged in user." msgstr "" @@ -3553,6 +3624,9 @@ msgstr "" msgid "Introducing Cycle Analytics" msgstr "" +msgid "Invitation" +msgstr "" + msgid "Invite" msgstr "" @@ -3640,7 +3714,7 @@ msgstr "" msgid "Job|The artifacts will be removed in" msgstr "" -msgid "Job|This job is stuck, because the project doesn't have any runners online assigned to it." +msgid "Job|This job is stuck because the project doesn't have any runners online assigned to it." msgstr "" msgid "Jul" @@ -3762,12 +3836,18 @@ msgstr "" msgid "Latest changes" msgstr "" +msgid "Latest pipeline for this branch" +msgstr "" + msgid "Learn more" msgstr "" msgid "Learn more about %{issue_boards_url}, to keep track of issues in multiple lists, using labels, assignees, and milestones. If you’re missing something from issue boards, please create an issue on %{gitlab_issues_url}." msgstr "" +msgid "Learn more about Auto DevOps" +msgstr "" + msgid "Learn more about Kubernetes" msgstr "" @@ -3995,6 +4075,18 @@ msgstr "" msgid "MergeRequests|View replaced file @ %{commitId}" msgstr "" +msgid "MergeRequests|started a discussion" +msgstr "" + +msgid "MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}" +msgstr "" + +msgid "MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}" +msgstr "" + +msgid "MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}" +msgstr "" + msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}" msgstr "" @@ -4034,6 +4126,9 @@ msgstr "" msgid "Metrics|Learn about environments" msgstr "" +msgid "Metrics|No data to display" +msgstr "" + msgid "Metrics|No deployed environments" msgstr "" @@ -4249,9 +4344,6 @@ msgstr "" msgid "No" msgstr "" -msgid "No Label" -msgstr "" - msgid "No assignee" msgstr "" @@ -4279,6 +4371,9 @@ msgstr "" msgid "No file chosen" msgstr "" +msgid "No file selected" +msgstr "" + msgid "No files found." msgstr "" @@ -4345,6 +4440,9 @@ msgstr "" msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}" msgstr "" +msgid "Note that this invitation was sent to %{mail_to_invite_email}, but you are signed in as %{link_to_current_user} with email %{mail_to_current_user}." +msgstr "" + msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token." msgstr "" @@ -4366,9 +4464,18 @@ msgstr "" msgid "Notes|Show history only" msgstr "" +msgid "Nothing here." +msgstr "" + msgid "Notification events" msgstr "" +msgid "Notification setting" +msgstr "" + +msgid "Notification setting - %{notification_title}" +msgstr "" + msgid "NotificationEvent|Close issue" msgstr "" @@ -4563,9 +4670,6 @@ msgstr "" msgid "People without permission will never get a notification and won't be able to comment." msgstr "" -msgid "Per job. If a job passes this threshold, it will be marked as failed" -msgstr "" - msgid "Perform advanced options such as changing path, transferring, or removing the group." msgstr "" @@ -4827,6 +4931,9 @@ msgstr "" msgid "Prioritized label" msgstr "" +msgid "Private" +msgstr "" + msgid "Private - Project access must be granted explicitly to each user." msgstr "" @@ -5253,6 +5360,9 @@ msgstr "" msgid "Provider" msgstr "" +msgid "Public" +msgstr "" + msgid "Public - The group and any public projects can be viewed without any authentication." msgstr "" @@ -5277,6 +5387,9 @@ msgstr "" msgid "Quick actions can be used in the issues description and comment boxes." msgstr "" +msgid "README" +msgstr "" + msgid "Read more" msgstr "" @@ -5286,9 +5399,6 @@ msgstr "" msgid "Read more about project permissions <strong>%{link_to_help}</strong>" msgstr "" -msgid "Readme" -msgstr "" - msgid "Real-time features" msgstr "" @@ -5426,6 +5536,12 @@ msgstr "" msgid "Repository URL" msgstr "" +msgid "Repository cleanup" +msgstr "" + +msgid "Repository cleanup has started. You will receive an email once the cleanup operation is complete." +msgstr "" + msgid "Repository maintenance" msgstr "" @@ -5468,6 +5584,9 @@ msgstr "" msgid "Resolve discussion" msgstr "" +msgid "Resolved" +msgstr "" + msgid "Response metrics (AWS ELB)" msgstr "" @@ -5749,6 +5868,45 @@ msgstr "" msgid "Server version" msgstr "" +msgid "Serverless" +msgstr "" + +msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster." +msgstr "" + +msgid "Serverless|An error occurred while retrieving serverless components" +msgstr "" + +msgid "Serverless|Domain" +msgstr "" + +msgid "Serverless|Function" +msgstr "" + +msgid "Serverless|Getting started with serverless" +msgstr "" + +msgid "Serverless|If you believe none of these apply, please check back later as the function data may be in the process of becoming available." +msgstr "" + +msgid "Serverless|Install Knative" +msgstr "" + +msgid "Serverless|Last Update" +msgstr "" + +msgid "Serverless|Learn more about Serverless" +msgstr "" + +msgid "Serverless|No functions available" +msgstr "" + +msgid "Serverless|Runtime" +msgstr "" + +msgid "Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:" +msgstr "" + msgid "Service Templates" msgstr "" @@ -6036,6 +6194,9 @@ msgstr "" msgid "Source is not available" msgstr "" +msgid "Source project cannot be found." +msgstr "" + msgid "Spam Logs" msgstr "" @@ -6093,6 +6254,9 @@ msgstr "" msgid "Start and due date" msgstr "" +msgid "Start cleanup" +msgstr "" + msgid "Start date" msgstr "" @@ -6168,11 +6332,6 @@ msgstr "" msgid "System metrics (Kubernetes)" msgstr "" -msgid "Tag (%{tag_count})" -msgid_plural "Tags (%{tag_count})" -msgstr[0] "" -msgstr[1] "" - msgid "Tags" msgstr "" @@ -6308,6 +6467,9 @@ msgstr "" msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" +msgid "The maximum file size allowed is %{max_attachment_size}mb" +msgstr "" + msgid "The maximum file size allowed is 200KB." msgstr "" @@ -6509,10 +6671,10 @@ msgstr "" msgid "This job is in pending state and is waiting to be picked by a runner" msgstr "" -msgid "This job is stuck, because you don't have any active runners online with any of these tags assigned to them:" +msgid "This job is stuck because you don't have any active runners online with any of these tags assigned to them:" msgstr "" -msgid "This job is stuck, because you don't have any active runners that can run this job." +msgid "This job is stuck because you don't have any active runners that can run this job." msgstr "" msgid "This job is the most recent deployment to %{link}." @@ -6542,6 +6704,9 @@ msgstr "" msgid "This page will be removed in a future release." msgstr "" +msgid "This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b>" +msgstr "" + msgid "This project" msgstr "" @@ -6569,7 +6734,7 @@ msgstr "" msgid "This source diff could not be displayed because it is too large." msgstr "" -msgid "This timeout will take precedence when lower than Project-defined timeout" +msgid "This timeout will take precedence when lower than project-defined timeout and accepts a human readable time input language like \"1 hour\". Values without specification represent seconds." msgstr "" msgid "This user has no identities" @@ -6987,6 +7152,9 @@ msgstr "" msgid "Upload file" msgstr "" +msgid "Upload object map" +msgstr "" + msgid "UploadLink|click to upload" msgstr "" @@ -7577,6 +7745,11 @@ msgstr "" msgid "disabled" msgstr "" +msgid "discussion resolved" +msgid_plural "discussions resolved" +msgstr[0] "" +msgstr[1] "" + msgid "done" msgstr "" @@ -7595,6 +7768,9 @@ msgstr "" msgid "from" msgstr "" +msgid "group" +msgstr "" + msgid "here" msgstr "" @@ -7841,6 +8017,9 @@ msgstr "" msgid "personal access token" msgstr "" +msgid "project" +msgstr "" + msgid "remaining" msgstr "" @@ -7852,6 +8031,9 @@ msgid_plural "replies" msgstr[0] "" msgstr[1] "" +msgid "should be higher than %{access} inherited membership from group %{group_name}" +msgstr "" + msgid "source" msgstr "" diff --git a/package.json b/package.json index 587b7b50c30..ac4d5174610 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "eslint": "eslint --max-warnings 0 --report-unused-disable-directives --ext .js,.vue .", "eslint-fix": "eslint --max-warnings 0 --report-unused-disable-directives --ext .js,.vue --fix .", "eslint-report": "eslint --max-warnings 0 --ext .js,.vue --format html --output-file ./eslint-report.html --no-inline-config .", + "jest": "BABEL_ENV=jest jest --config=config/jest.config.js", "karma": "BABEL_ENV=${BABEL_ENV:=karma} karma start --single-run true config/karma.config.js", "karma-coverage": "BABEL_ENV=coverage karma start --single-run true config/karma.config.js", "karma-start": "BABEL_ENV=karma karma start config/karma.config.js", @@ -24,25 +25,25 @@ "@babel/plugin-syntax-dynamic-import": "^7.0.0", "@babel/plugin-syntax-import-meta": "^7.0.0", "@babel/preset-env": "^7.1.0", - "@gitlab/svgs": "^1.38.0", - "@gitlab/ui": "^1.11.0", + "@gitlab/svgs": "^1.40.0", + "@gitlab/ui": "^1.14.0", "apollo-boost": "^0.1.20", "apollo-client": "^2.4.5", "autosize": "^4.0.0", "axios": "^0.17.1", "babel-loader": "^8.0.4", - "bootstrap": "4.1.1", + "bootstrap": "4.1.3", "brace-expansion": "^1.1.8", "cache-loader": "^1.2.2", "chart.js": "1.0.2", "classlist-polyfill": "^1.2.0", "clipboard": "^1.7.1", - "codesandbox-api": "^0.0.18", + "codesandbox-api": "^0.0.20", "compression-webpack-plugin": "^2.0.0", "core-js": "^2.4.1", "cropper": "^2.3.0", "css-loader": "^1.0.0", - "d3": "4.12.2", + "d3": "^4.13.0", "d3-array": "^1.2.1", "d3-axis": "^1.0.8", "d3-brush": "^1.0.4", @@ -86,7 +87,7 @@ "sanitize-html": "^1.16.1", "select2": "3.5.2-browserify", "sha1": "^1.1.1", - "smooshpack": "^0.0.48", + "smooshpack": "^0.0.53", "sortablejs": "^1.7.0", "sql.js": "^0.4.0", "stickyfilljs": "^2.0.5", @@ -118,17 +119,23 @@ "@gitlab/eslint-config": "^1.2.0", "@vue/test-utils": "^1.0.0-beta.25", "axios-mock-adapter": "^1.15.0", + "babel-core": "^7.0.0-bridge", + "babel-jest": "^23.6.0", + "babel-plugin-dynamic-import-node": "^2.2.0", "babel-plugin-istanbul": "^5.1.0", "babel-plugin-rewire": "^1.2.0", + "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", "babel-template": "^6.26.0", "babel-types": "^6.26.0", "chalk": "^2.4.1", "commander": "^2.18.0", "eslint": "~5.6.0", + "eslint-import-resolver-jest": "^2.1.1", "eslint-import-resolver-webpack": "^0.10.1", "eslint-plugin-html": "4.0.5", "eslint-plugin-import": "^2.14.0", "eslint-plugin-jasmine": "^2.10.1", + "eslint-plugin-jest": "^22.1.0", "gettext-extractor": "^3.3.2", "gettext-extractor-vue": "^4.0.1", "graphql-tag": "^2.10.0", @@ -136,6 +143,8 @@ "jasmine-core": "^2.9.0", "jasmine-diff": "^0.1.3", "jasmine-jquery": "^2.1.1", + "jest": "^23.6.0", + "jest-junit": "^5.2.0", "karma": "^3.0.0", "karma-chrome-launcher": "^2.2.0", "karma-coverage-istanbul-reporter": "^2.0.4", diff --git a/public/robots.txt b/public/robots.txt index ea931e1a223..7908297564e 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -70,3 +70,5 @@ Disallow: /*/*/hooks Disallow: /*/*/services Disallow: /*/*/protected_branches Disallow: /*/*/uploads/ +Disallow: /*/-/group_members +Disallow: /*/project_members diff --git a/qa/README.md b/qa/README.md index 746bd5cf94b..08ba59e117d 100644 --- a/qa/README.md +++ b/qa/README.md @@ -80,6 +80,15 @@ GITLAB_USERNAME=jsmith GITLAB_PASSWORD=password GITLAB_SANDBOX_NAME=jsmith-qa-sa All [supported environment variables are here](https://gitlab.com/gitlab-org/gitlab-qa/blob/master/docs/what_tests_can_be_run.md#supported-environment-variables). +### Sending additional cookies + +The environment variable `QA_COOKIES` can be set to send additional cookies +on every request. This is necessary on gitlab.com to direct traffic to the +canary fleet. To do this set `QA_COOKIES="gitlab_canary=true"`. + +To set multiple cookies, separate them with the `;` character, for example: `QA_COOKIES="cookie1=value;cookie2=value2"` + + ### Building a Docker image to test Once you have made changes to the CE/EE repositories, you may want to build a @@ -184,6 +184,7 @@ module QA autoload :Runners, 'qa/page/project/settings/runners' autoload :MergeRequest, 'qa/page/project/settings/merge_request' autoload :Members, 'qa/page/project/settings/members' + autoload :MirroringRepositories, 'qa/page/project/settings/mirroring_repositories' end module Issue diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb index 91e229c4c8c..f4bba3c9560 100644 --- a/qa/qa/page/base.rb +++ b/qa/qa/page/base.rb @@ -15,7 +15,7 @@ module QA def_delegators :evaluator, :view, :views def refresh - visit current_url + page.refresh end def wait(max: 60, time: 0.1, reload: true) @@ -80,8 +80,8 @@ module QA page.evaluate_script('xhr.status') == 200 end - def find_element(name) - find(element_selector_css(name)) + def find_element(name, wait: Capybara.default_max_wait_time) + find(element_selector_css(name), wait: wait) end def all_elements(name) @@ -100,6 +100,14 @@ module QA find_element(name).set(content) end + def select_element(name, value) + element = find_element(name) + + return if element.text.downcase.to_s == value.to_s + + element.select value.to_s.capitalize + end + def has_element?(name) has_css?(element_selector_css(name)) end @@ -110,6 +118,12 @@ module QA end end + def within_element_by_index(name, index) + page.within all_elements(name)[index] do + yield + end + end + def scroll_to_element(name, *args) scroll_to(element_selector_css(name), *args) end diff --git a/qa/qa/page/project/settings/mirroring_repositories.rb b/qa/qa/page/project/settings/mirroring_repositories.rb new file mode 100644 index 00000000000..a73be7dfeda --- /dev/null +++ b/qa/qa/page/project/settings/mirroring_repositories.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module QA + module Page + module Project + module Settings + class MirroringRepositories < Page::Base + view 'app/views/projects/mirrors/_authentication_method.html.haml' do + element :authentication_method + element :password + end + + view 'app/views/projects/mirrors/_mirror_repos.html.haml' do + element :mirror_repository_url_input + element :mirror_repository_button + element :mirror_repository_url + element :mirror_last_update_at + element :mirrored_repository_row + end + + view 'app/views/projects/mirrors/_mirror_repos_form.html.haml' do + element :mirror_direction + end + + view 'app/views/shared/_remote_mirror_update_button.html.haml' do + element :update_now_button + end + + def repository_url=(value) + fill_element :mirror_repository_url_input, value + end + + def password=(value) + fill_element :password, value + end + + def mirror_direction=(value) + raise ArgumentError, "Mirror direction must be :push or :pull" unless [:push, :pull].include? value + + select_element(:mirror_direction, value) + end + + def authentication_method=(value) + raise ArgumentError, "Authentication method must be :password or :none" unless [:password, :none].include? value + + select_element(:authentication_method, value) + end + + def mirror_repository + click_element :mirror_repository_button + end + + def update(url) + row_index = find_repository_row_index url + + within_element_by_index(:mirrored_repository_row, row_index) do + click_element :update_now_button + end + + # Wait a few seconds for the sync to occur and then refresh the page + # so that 'last update' shows 'just now' or a period in seconds + sleep 5 + refresh + + wait(time: 1) do + within_element_by_index(:mirrored_repository_row, row_index) do + last_update = find_element(:mirror_last_update_at, wait: 0) + last_update.has_text?('just now') || last_update.has_text?('seconds') + end + end + + # Fail early if the page still shows that there has been no update + within_element_by_index(:mirrored_repository_row, row_index) do + find_element(:mirror_last_update_at, wait: 0).assert_no_text('Never') + end + end + + private + + def find_repository_row_index(target_url) + all_elements(:mirror_repository_url).index do |url| + # The url might be a sanitized url but the target_url won't be so + # we compare just the paths instead of the full url + URI.parse(url.text).path == target_url.path + end + end + end + end + end + end +end diff --git a/qa/qa/page/project/settings/repository.rb b/qa/qa/page/project/settings/repository.rb index 53ebe28970b..ac0b87aca5e 100644 --- a/qa/qa/page/project/settings/repository.rb +++ b/qa/qa/page/project/settings/repository.rb @@ -13,6 +13,10 @@ module QA element :protected_branches_settings end + view 'app/views/projects/mirrors/_mirror_repos.html.haml' do + element :mirroring_repositories_settings + end + def expand_deploy_keys(&block) expand_section(:deploy_keys_settings) do DeployKeys.perform(&block) @@ -30,6 +34,12 @@ module QA DeployTokens.perform(&block) end end + + def expand_mirroring_repositories(&block) + expand_section(:mirroring_repositories_settings) do + MirroringRepositories.perform(&block) + end + end end end end diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb index 9aaf57e8d83..7fd2ba25527 100644 --- a/qa/qa/runtime/browser.rb +++ b/qa/qa/runtime/browser.rb @@ -117,6 +117,15 @@ module QA def perform(&block) visit(url) + if QA::Runtime::Env.qa_cookies + browser = Capybara.current_session.driver.browser + QA::Runtime::Env.qa_cookies.each do |cookie| + name, value = cookie.split("=") + value ||= "" + browser.manage.add_cookie name: name, value: value + end + end + yield.tap { clear! } if block_given? end diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb index 7b2768548dd..3bc2b44ccd8 100644 --- a/qa/qa/runtime/env.rb +++ b/qa/qa/runtime/env.rb @@ -38,6 +38,10 @@ module QA ENV['CI'] || ENV['CI_SERVER'] end + def qa_cookies + ENV['QA_COOKIES'] && ENV['QA_COOKIES'].split(';') + end + def signup_disabled? enabled?(ENV['SIGNUP_DISABLED'], default: false) end diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/log_in_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/log_in_spec.rb index dae2a9e0236..8f24a27b26f 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/login/log_in_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/login/log_in_spec.rb @@ -1,7 +1,7 @@ module QA context 'Manage', :smoke do describe 'basic user login' do - it 'user logs in using basic credentials' do + it 'user logs in using basic credentials and logs out' do Runtime::Browser.visit(:gitlab, Page::Main::Login) Page::Main::Login.act { sign_in_using_credentials } @@ -11,6 +11,15 @@ module QA Page::Main::Menu.perform do |menu| expect(menu).to have_personal_area end + + Page::Main::Menu.perform do |menu| + menu.sign_out + expect(menu).not_to have_personal_area + end + + Page::Main::Login.perform do |form| + expect(form.sign_in_tab?).to be(true) + end end end end diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_over_http_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_over_http_spec.rb new file mode 100644 index 00000000000..2d0e281ab59 --- /dev/null +++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_over_http_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module QA + context 'Create' do + describe 'Push mirror a repository over HTTP' do + it 'configures and syncs a (push) mirrored repository' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.perform(&:sign_in_using_credentials) + + target_project = Resource::Project.fabricate! do |project| + project.name = 'push-mirror-target-project' + end + target_project_uri = target_project.repository_http_location.uri + target_project_uri.user = Runtime::User.username + + source_project_push = Resource::Repository::ProjectPush.fabricate! do |push| + push.file_name = 'README.md' + push.file_content = '# This is a test project' + push.commit_message = 'Add README.md' + end + source_project_push.project.visit! + + Page::Project::Show.perform(&:wait_for_push) + + Page::Project::Menu.perform(&:click_repository_settings) + Page::Project::Settings::Repository.perform do |settings| + settings.expand_mirroring_repositories do |mirror_settings| + # Configure the source project to push to the target project + mirror_settings.repository_url = target_project_uri + mirror_settings.mirror_direction = :push + mirror_settings.authentication_method = :password + mirror_settings.password = Runtime::User.password + mirror_settings.mirror_repository + mirror_settings.update target_project_uri + end + end + + # Check that the target project has the commit from the source + target_project.visit! + expect(page).to have_content('README.md') + expect(page).to have_content('This is a test project') + end + end + end +end diff --git a/qa/qa/support/page/logging.rb b/qa/qa/support/page/logging.rb index cf5cd3a79f8..43bc16d8c9a 100644 --- a/qa/qa/support/page/logging.rb +++ b/qa/qa/support/page/logging.rb @@ -37,8 +37,8 @@ module QA exists end - def find_element(name) - log("finding :#{name}") + def find_element(name, wait: Capybara.default_max_wait_time) + log("finding :#{name} (wait: #{wait})") element = super @@ -71,6 +71,12 @@ module QA super end + def select_element(name, value) + log(%Q(selecting "#{value}" in :#{name})) + + super + end + def has_element?(name) found = super @@ -89,6 +95,16 @@ module QA element end + def within_element_by_index(name, index) + log("within elements :#{name} at index #{index}") + + element = super + + log("end within elements :#{name} at index #{index}") + + element + end + private def log(msg) diff --git a/rubocop/cop/migration/add_reference.rb b/rubocop/cop/migration/add_reference.rb index 4b67270c97a..1d471b9797e 100644 --- a/rubocop/cop/migration/add_reference.rb +++ b/rubocop/cop/migration/add_reference.rb @@ -8,7 +8,7 @@ module RuboCop class AddReference < RuboCop::Cop::Cop include MigrationHelpers - MSG = '`add_reference` requires `index: true`' + MSG = '`add_reference` requires `index: true` or `index: { options... }`' def on_send(node) return unless in_migration?(node) @@ -33,7 +33,12 @@ module RuboCop private def index_enabled?(pair) - hash_key_type(pair) == :sym && hash_key_name(pair) == :index && pair.children[1].true_type? + return unless hash_key_type(pair) == :sym + return unless hash_key_name(pair) == :index + + index = pair.children[1] + + index.true_type? || index.hash_type? end def hash_key_type(pair) diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh index 00e23f12bc0..b50bf2161cb 100755 --- a/scripts/review_apps/review-apps.sh +++ b/scripts/review_apps/review-apps.sh @@ -1,6 +1,8 @@ [[ "$TRACE" ]] && set -x export TILLER_NAMESPACE="$KUBE_NAMESPACE" +function echoerr() { printf "\033[0;31m%s\n\033[0m" "$*" >&2; } + function check_kube_domain() { if [ -z ${REVIEW_APPS_DOMAIN+x} ]; then echo "In order to deploy or use Review Apps, REVIEW_APPS_DOMAIN variable must be set" @@ -88,19 +90,16 @@ function deploy() { replicas="1" service_enabled="false" postgres_enabled="$POSTGRES_ENABLED" - gitlab_migrations_image_repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitlab-rails-ce" - gitlab_sidekiq_image_repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitlab-sidekiq-ce" - gitlab_unicorn_image_repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitlab-unicorn-ce" - gitlab_gitaly_image_repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitaly" - gitlab_shell_image_repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitlab-shell" - gitlab_workhorse_image_repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitlab-workhorse-ce" - - if [[ "$CI_PROJECT_NAME" == "gitlab-ee" ]]; then - gitlab_migrations_image_repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitlab-rails-ee" - gitlab_sidekiq_image_repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitlab-sidekiq-ee" - gitlab_unicorn_image_repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitlab-unicorn-ee" - gitlab_workhorse_image_repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitlab-workhorse-ee" - fi + + IMAGE_REPOSITORY="registry.gitlab.com/gitlab-org/build/cng-mirror" + IMAGE_VERSION="${CI_PROJECT_NAME#gitlab-}" + gitlab_migrations_image_repository="${IMAGE_REPOSITORY}/gitlab-rails-${IMAGE_VERSION}" + gitlab_sidekiq_image_repository="${IMAGE_REPOSITORY}/gitlab-sidekiq-${IMAGE_VERSION}" + gitlab_unicorn_image_repository="${IMAGE_REPOSITORY}/gitlab-unicorn-${IMAGE_VERSION}" + gitlab_task_runner_image_repository="${IMAGE_REPOSITORY}/gitlab-task-runner-${IMAGE_VERSION}" + gitlab_gitaly_image_repository="${IMAGE_REPOSITORY}/gitaly" + gitlab_shell_image_repository="${IMAGE_REPOSITORY}/gitlab-shell" + gitlab_workhorse_image_repository="${IMAGE_REPOSITORY}/gitlab-workhorse-${IMAGE_VERSION}" # canary uses stable db [[ "$track" == "canary" ]] && postgres_enabled="false" @@ -155,6 +154,8 @@ HELM_CMD=$(cat << EOF --set gitlab.sidekiq.image.tag="$CI_COMMIT_REF_NAME" \ --set gitlab.unicorn.image.repository="$gitlab_unicorn_image_repository" \ --set gitlab.unicorn.image.tag="$CI_COMMIT_REF_NAME" \ + --set gitlab.task-runner.image.repository="$gitlab_task_runner_image_repository" \ + --set gitlab.task-runner.image.tag="$CI_COMMIT_REF_NAME" \ --set gitlab.gitaly.image.repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitaly" \ --set gitlab.gitaly.image.tag="v$GITALY_VERSION" \ --set gitlab.gitlab-shell.image.repository="registry.gitlab.com/gitlab-org/build/cng-mirror/gitlab-shell" \ @@ -229,3 +230,122 @@ function install_external_dns() { --set rbac.create="true" fi } + +function get_pod() { + local app_name="${1}" + local status="${2-Running}" + get_pod_cmd="kubectl get pods -n ${KUBE_NAMESPACE} --field-selector=status.phase=${status} -lapp=${app_name},release=${CI_ENVIRONMENT_SLUG} --no-headers -o=custom-columns=NAME:.metadata.name" + echoerr "Running '${get_pod_cmd}'" + + while true; do + local pod_name="$(eval $get_pod_cmd)" + [[ "${pod_name}" == "" ]] || break + + echoerr "Waiting till '${app_name}' pod is ready"; + sleep 5; + done + + echoerr "The pod name is '${pod_name}'." + echo "${pod_name}" +} + +function add_license() { + if [ -z "${REVIEW_APPS_EE_LICENSE}" ]; then echo "License not found" && return; fi + + task_runner_pod=$(get_pod "task-runner"); + if [ -z "${task_runner_pod}" ]; then echo "Task runner pod not found" && return; fi + + echo "${REVIEW_APPS_EE_LICENSE}" > /tmp/license.gitlab + kubectl -n "$KUBE_NAMESPACE" cp /tmp/license.gitlab ${task_runner_pod}:/tmp/license.gitlab + rm /tmp/license.gitlab + + kubectl -n "$KUBE_NAMESPACE" exec -it ${task_runner_pod} -- /srv/gitlab/bin/rails runner -e production \ + ' + content = File.read("/tmp/license.gitlab").strip; + FileUtils.rm_f("/tmp/license.gitlab"); + + unless License.where(data:content).empty? + puts "License already exists"; + Kernel.exit 0; + end + + unless License.new(data: content).save + puts "Could not add license"; + Kernel.exit 0; + end + + puts "License added"; + ' +} + +function get_job_id() { + local job_name="${1}" + local query_string="${2:+&${2}}" + + local max_page=3 + local page=1 + + while true; do + local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/jobs?per_page=100&page=${page}${query_string}" + echoerr "GET ${url}" + + local job_id=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq "map(select(.name == \"${job_name}\")) | map(.id) | last") + [[ "${job_id}" == "null" && "${page}" -lt "$max_page" ]] || break + + ((page++)) + done + + if [[ "${job_id}" == "" ]]; then + echoerr "The '${job_name}' job ID couldn't be retrieved!" + else + echoerr "The '${job_name}' job ID is ${job_id}" + echo "${job_id}" + fi +} + +function play_job() { + local job_name="${1}" + local job_id=$(get_job_id "${job_name}" "scope=manual"); + if [ -z "${job_id}" ]; then return; fi + + local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/jobs/${job_id}/play" + echoerr "POST ${url}" + + local job_url=$(curl --silent --show-error --request POST --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq ".web_url") + echo "Manual job '${job_name}' started at: ${job_url}" +} + +function wait_for_job_to_be_done() { + local job_name="${1}" + local query_string="${2}" + local job_id=$(get_job_id "${job_name}" "${query_string}"); + if [ -z "${job_id}" ]; then return; fi + + echoerr "Waiting for the '${job_name}' job to finish..." + + local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/jobs/${job_id}" + echo "GET ${url}" + + # In case the job hasn't finished yet. Keep trying until the job times out. + local interval=30 + local elapsed_seconds=0 + while true; do + local job_status=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq ".status" | sed -e s/\"//g) + [[ "${job_status}" == "pending" || "${job_status}" == "running" ]] || break + + printf "." + ((elapsed_seconds+=$interval)) + sleep ${interval} + done + + local elapsed_minutes=$((elapsed_seconds / 60)) + echoerr "Waited '${job_name}' for ${elapsed_minutes} minutes." + + if [[ "${job_status}" == "failed" ]]; then + echo "The '${job_name}' failed." + elif [[ "${job_status}" == "manual" ]]; then + echo "The '${job_name}' is manual." + else + echo "The '${job_name}' passed." + fi +} diff --git a/scripts/trigger-build b/scripts/trigger-build index d2c71f5be3e..14af3281106 100755 --- a/scripts/trigger-build +++ b/scripts/trigger-build @@ -68,6 +68,7 @@ module Trigger def base_variables { + 'GITLAB_REF_SLUG' => ref_slug, 'TRIGGERED_USER' => ENV['TRIGGERED_USER'] || ENV['GITLAB_USER_NAME'], 'TRIGGER_SOURCE' => ENV['CI_JOB_URL'], 'TOP_UPSTREAM_SOURCE_PROJECT' => ENV['CI_PROJECT_PATH'], @@ -76,6 +77,12 @@ module Trigger } end + def ref_slug + return 'master' if ENV['CI_COMMIT_REF_SLUG'] =~ %r{(\Aqa[/-]|-qa\z)} + + ENV['CI_COMMIT_REF_SLUG'] + end + # Read version files from all components def version_file_variables Dir.glob("*_VERSION").each_with_object({}) do |version_file, params| @@ -106,18 +113,11 @@ module Trigger def extra_variables { 'GITLAB_VERSION' => ENV['CI_COMMIT_SHA'], - 'GITLAB_REF_SLUG' => ref_slug, 'ALTERNATIVE_SOURCES' => 'true', 'ee' => Trigger.ee? ? 'true' : 'false', 'QA_BRANCH' => ENV['QA_BRANCH'] || 'master' } end - - def ref_slug - return 'master' if ENV['CI_COMMIT_REF_SLUG'] =~ %r{(\Aqa[/-]|-qa\z)} - - ENV['CI_COMMIT_REF_SLUG'] - end end class CNG < Base diff --git a/spec/config/settings_spec.rb b/spec/config/settings_spec.rb index 83b2de47741..c89b5f48dc0 100644 --- a/spec/config/settings_spec.rb +++ b/spec/config/settings_spec.rb @@ -6,4 +6,102 @@ describe Settings do expect(described_class.omniauth.enabled).to be true end end + + describe '.attr_encrypted_db_key_base_truncated' do + it 'is a string with maximum 32 bytes size' do + expect(described_class.attr_encrypted_db_key_base_truncated.bytesize) + .to be <= 32 + end + end + + describe '.attr_encrypted_db_key_base_12' do + context 'when db key base secret is less than 12 bytes' do + before do + allow(described_class) + .to receive(:attr_encrypted_db_key_base) + .and_return('a' * 10) + end + + it 'expands db key base secret to 12 bytes' do + expect(described_class.attr_encrypted_db_key_base_12) + .to eq(('a' * 10) + ('0' * 2)) + end + end + + context 'when key has multiple multi-byte UTF chars exceeding 12 bytes' do + before do + allow(described_class) + .to receive(:attr_encrypted_db_key_base) + .and_return('❤' * 18) + end + + it 'does not use more than 32 bytes' do + db_key_base = described_class.attr_encrypted_db_key_base_12 + + expect(db_key_base).to eq('❤' * 4) + expect(db_key_base.bytesize).to eq 12 + end + end + end + + describe '.attr_encrypted_db_key_base_32' do + context 'when db key base secret is less than 32 bytes' do + before do + allow(described_class) + .to receive(:attr_encrypted_db_key_base) + .and_return('a' * 10) + end + + it 'expands db key base secret to 32 bytes' do + expanded_key_base = ('a' * 10) + ('0' * 22) + + expect(expanded_key_base.bytesize).to eq 32 + expect(described_class.attr_encrypted_db_key_base_32) + .to eq expanded_key_base + end + end + + context 'when db key base secret is 32 bytes' do + before do + allow(described_class) + .to receive(:attr_encrypted_db_key_base) + .and_return('a' * 32) + end + + it 'returns original value' do + expect(described_class.attr_encrypted_db_key_base_32) + .to eq 'a' * 32 + end + end + + context 'when db key base contains multi-byte UTF character' do + before do + allow(described_class) + .to receive(:attr_encrypted_db_key_base) + .and_return('❤' * 6) + end + + it 'does not use more than 32 bytes' do + db_key_base = described_class.attr_encrypted_db_key_base_32 + + expect(db_key_base).to eq '❤❤❤❤❤❤' + ('0' * 14) + expect(db_key_base.bytesize).to eq 32 + end + end + + context 'when db key base multi-byte UTF chars exceeding 32 bytes' do + before do + allow(described_class) + .to receive(:attr_encrypted_db_key_base) + .and_return('❤' * 18) + end + + it 'does not use more than 32 bytes' do + db_key_base = described_class.attr_encrypted_db_key_base_32 + + expect(db_key_base).to eq(('❤' * 10) + ('0' * 2)) + expect(db_key_base.bytesize).to eq 32 + end + end + end end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index f350641a643..3dd0b2623ac 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -264,5 +264,17 @@ describe Admin::UsersController do expect(flash[:alert]).to eq("You are now impersonating #{user.username}") end end + + context "when impersonation is disabled" do + before do + stub_config_setting(impersonation_enabled: false) + end + + it "shows error page" do + post :impersonate, id: user.username + + expect(response).to have_gitlab_http_status(404) + end + end end end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index efc3ce74627..c2bd7fd9808 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -107,59 +107,6 @@ describe ApplicationController do end end - describe "#authenticate_user_from_personal_access_token!" do - before do - stub_authentication_activity_metrics(debug: false) - end - - controller(described_class) do - def index - render text: 'authenticated' - end - end - - let(:personal_access_token) { create(:personal_access_token, user: user) } - - context "when the 'personal_access_token' param is populated with the personal access token" do - it "logs the user in" do - expect(authentication_metrics) - .to increment(:user_authenticated_counter) - .and increment(:user_session_override_counter) - .and increment(:user_sessionless_authentication_counter) - - get :index, private_token: personal_access_token.token - - expect(response).to have_gitlab_http_status(200) - expect(response.body).to eq('authenticated') - end - end - - context "when the 'PERSONAL_ACCESS_TOKEN' header is populated with the personal access token" do - it "logs the user in" do - expect(authentication_metrics) - .to increment(:user_authenticated_counter) - .and increment(:user_session_override_counter) - .and increment(:user_sessionless_authentication_counter) - - @request.headers["PRIVATE-TOKEN"] = personal_access_token.token - get :index - - expect(response).to have_gitlab_http_status(200) - expect(response.body).to eq('authenticated') - end - end - - it "doesn't log the user in otherwise" do - expect(authentication_metrics) - .to increment(:user_unauthenticated_counter) - - get :index, private_token: "token" - - expect(response.status).not_to eq(200) - expect(response.body).not_to eq('authenticated') - end - end - describe 'session expiration' do controller(described_class) do # The anonymous controller will report 401 and fail to run any actions. @@ -167,7 +114,7 @@ describe ApplicationController do skip_before_action :authenticate_user!, only: :index def index - render text: 'authenticated' + render html: 'authenticated' end end @@ -224,74 +171,6 @@ describe ApplicationController do end end - describe '#authenticate_sessionless_user!' do - before do - stub_authentication_activity_metrics(debug: false) - end - - describe 'authenticating a user from a feed token' do - controller(described_class) do - def index - render text: 'authenticated' - end - end - - context "when the 'feed_token' param is populated with the feed token" do - context 'when the request format is atom' do - it "logs the user in" do - expect(authentication_metrics) - .to increment(:user_authenticated_counter) - .and increment(:user_session_override_counter) - .and increment(:user_sessionless_authentication_counter) - - get :index, feed_token: user.feed_token, format: :atom - - expect(response).to have_gitlab_http_status 200 - expect(response.body).to eq 'authenticated' - end - end - - context 'when the request format is ics' do - it "logs the user in" do - expect(authentication_metrics) - .to increment(:user_authenticated_counter) - .and increment(:user_session_override_counter) - .and increment(:user_sessionless_authentication_counter) - - get :index, feed_token: user.feed_token, format: :ics - - expect(response).to have_gitlab_http_status 200 - expect(response.body).to eq 'authenticated' - end - end - - context 'when the request format is neither atom nor ics' do - it "doesn't log the user in" do - expect(authentication_metrics) - .to increment(:user_unauthenticated_counter) - - get :index, feed_token: user.feed_token - - expect(response.status).not_to have_gitlab_http_status 200 - expect(response.body).not_to eq 'authenticated' - end - end - end - - context "when the 'feed_token' param is populated with an invalid feed token" do - it "doesn't log the user" do - expect(authentication_metrics) - .to increment(:user_unauthenticated_counter) - - get :index, feed_token: 'token', format: :atom - - expect(response.status).not_to eq 200 - expect(response.body).not_to eq 'authenticated' - end - end - end - end - describe '#route_not_found' do it 'renders 404 if authenticated' do allow(controller).to receive(:current_user).and_return(user) @@ -522,7 +401,7 @@ describe ApplicationController do context 'terms' do controller(described_class) do def index - render text: 'authenticated' + render html: 'authenticated' end end @@ -557,36 +436,6 @@ describe ApplicationController do expect(response).to have_gitlab_http_status(200) end - - context 'for sessionless users' do - render_views - - before do - sign_out user - end - - it 'renders a 403 when the sessionless user did not accept the terms' do - get :index, feed_token: user.feed_token, format: :atom - - expect(response).to have_gitlab_http_status(403) - end - - it 'renders the error message when the format was html' do - get :index, - private_token: create(:personal_access_token, user: user).token, - format: :html - - expect(response.body).to have_content /accept the terms of service/i - end - - it 'renders a 200 when the sessionless user accepted the terms' do - accept_terms(user) - - get :index, feed_token: user.feed_token, format: :atom - - expect(response).to have_gitlab_http_status(200) - end - end end end @@ -595,7 +444,7 @@ describe ApplicationController do attr_reader :last_payload def index - render text: 'authenticated' + render html: 'authenticated' end def append_info_to_payload(payload) @@ -611,6 +460,14 @@ describe ApplicationController do expect(controller.last_payload.has_key?(:response)).to be_falsey end + it 'does log correlation id' do + Gitlab::CorrelationId.use_id('new-id') do + get :index + end + + expect(controller.last_payload).to include('correlation_id' => 'new-id') + end + context '422 errors' do it 'logs a response with a string' do response = spy(ActionDispatch::Response, status: 422, body: 'Hello world', content_type: 'application/json', cookies: {}) diff --git a/spec/controllers/boards/issues_controller_spec.rb b/spec/controllers/boards/issues_controller_spec.rb index 98946e4287b..6d0483f0032 100644 --- a/spec/controllers/boards/issues_controller_spec.rb +++ b/spec/controllers/boards/issues_controller_spec.rb @@ -50,7 +50,7 @@ describe Boards::IssuesController do parsed_response = JSON.parse(response.body) - expect(response).to match_response_schema('issues') + expect(response).to match_response_schema('entities/issue_boards') expect(parsed_response['issues'].length).to eq 2 expect(development.issues.map(&:relative_position)).not_to include(nil) end @@ -121,7 +121,7 @@ describe Boards::IssuesController do parsed_response = JSON.parse(response.body) - expect(response).to match_response_schema('issues') + expect(response).to match_response_schema('entities/issue_boards') expect(parsed_response['issues'].length).to eq 2 end end @@ -168,7 +168,7 @@ describe Boards::IssuesController do it 'returns the created issue' do create_issue user: user, board: board, list: list1, title: 'New issue' - expect(response).to match_response_schema('issue') + expect(response).to match_response_schema('entities/issue_board') end end diff --git a/spec/controllers/concerns/issuable_collections_spec.rb b/spec/controllers/concerns/issuable_collections_spec.rb index e93c923fd39..f87eed6ff9f 100644 --- a/spec/controllers/concerns/issuable_collections_spec.rb +++ b/spec/controllers/concerns/issuable_collections_spec.rb @@ -86,6 +86,7 @@ describe IssuableCollections do it 'only allows whitelisted params' do allow(controller).to receive(:cookies).and_return({}) + allow(controller).to receive(:current_user).and_return(nil) finder_options = controller.send(:finder_options) diff --git a/spec/controllers/dashboard/projects_controller_spec.rb b/spec/controllers/dashboard/projects_controller_spec.rb new file mode 100644 index 00000000000..2975205e09c --- /dev/null +++ b/spec/controllers/dashboard/projects_controller_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe Dashboard::ProjectsController do + it_behaves_like 'authenticates sessionless user', :index, :atom +end diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb index b4a731fd3a3..e2c799f5205 100644 --- a/spec/controllers/dashboard/todos_controller_spec.rb +++ b/spec/controllers/dashboard/todos_controller_spec.rb @@ -42,6 +42,16 @@ describe Dashboard::TodosController do end end + context 'group authorization' do + it 'renders 404 when user does not have read access on given group' do + unauthorized_group = create(:group, :private) + + get :index, group_id: unauthorized_group.id + + expect(response).to have_gitlab_http_status(404) + end + end + context 'when using pagination' do let(:last_page) { user.todos.page.total_pages } let!(:issues) { create_list(:issue, 3, project: project, assignees: [user]) } diff --git a/spec/controllers/dashboard_controller_spec.rb b/spec/controllers/dashboard_controller_spec.rb index 187542ba30c..c857a78d5e8 100644 --- a/spec/controllers/dashboard_controller_spec.rb +++ b/spec/controllers/dashboard_controller_spec.rb @@ -1,21 +1,26 @@ require 'spec_helper' describe DashboardController do - let(:user) { create(:user) } - let(:project) { create(:project) } + context 'signed in' do + let(:user) { create(:user) } + let(:project) { create(:project) } - before do - project.add_maintainer(user) - sign_in(user) - end + before do + project.add_maintainer(user) + sign_in(user) + end - describe 'GET issues' do - it_behaves_like 'issuables list meta-data', :issue, :issues - it_behaves_like 'issuables requiring filter', :issues - end + describe 'GET issues' do + it_behaves_like 'issuables list meta-data', :issue, :issues + it_behaves_like 'issuables requiring filter', :issues + end - describe 'GET merge requests' do - it_behaves_like 'issuables list meta-data', :merge_request, :merge_requests - it_behaves_like 'issuables requiring filter', :merge_requests + describe 'GET merge requests' do + it_behaves_like 'issuables list meta-data', :merge_request, :merge_requests + it_behaves_like 'issuables requiring filter', :merge_requests + end end + + it_behaves_like 'authenticates sessionless user', :issues, :atom, author_id: User.first + it_behaves_like 'authenticates sessionless user', :issues_calendar, :ics end diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb index 1449036e148..949ad532365 100644 --- a/spec/controllers/graphql_controller_spec.rb +++ b/spec/controllers/graphql_controller_spec.rb @@ -52,15 +52,58 @@ describe GraphqlController do end end + context 'token authentication' do + before do + stub_authentication_activity_metrics(debug: false) + end + + let(:user) { create(:user, username: 'Simon') } + let(:personal_access_token) { create(:personal_access_token, user: user) } + + context "when the 'personal_access_token' param is populated with the personal access token" do + it 'logs the user in' do + expect(authentication_metrics) + .to increment(:user_authenticated_counter) + .and increment(:user_session_override_counter) + .and increment(:user_sessionless_authentication_counter) + + run_test_query!(private_token: personal_access_token.token) + + expect(response).to have_gitlab_http_status(200) + expect(query_response).to eq('echo' => '"Simon" says: test success') + end + end + + context 'when the personal access token has no api scope' do + it 'does not log the user in' do + personal_access_token.update(scopes: [:read_user]) + + run_test_query!(private_token: personal_access_token.token) + + expect(response).to have_gitlab_http_status(200) + + expect(query_response).to eq('echo' => 'nil says: test success') + end + end + + context 'without token' do + it 'shows public data' do + run_test_query! + + expect(query_response).to eq('echo' => 'nil says: test success') + end + end + end + # Chosen to exercise all the moving parts in GraphqlController#execute - def run_test_query!(variables: { 'text' => 'test success' }) + def run_test_query!(variables: { 'text' => 'test success' }, private_token: nil) query = <<~QUERY query Echo($text: String) { echo(text: $text) } QUERY - post :execute, query: query, operationName: 'Echo', variables: variables + post :execute, query: query, operationName: 'Echo', variables: variables, private_token: private_token end def query_response diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 4de61b65f71..4b0dc4c9b69 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -226,9 +226,10 @@ describe GroupsController do end context 'searching' do - # Remove as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/52271 before do + # Remove in https://gitlab.com/gitlab-org/gitlab-ce/issues/54643 stub_feature_flags(use_cte_for_group_issues_search: false) + stub_feature_flags(use_subquery_for_group_issues_search: true) end it 'works with popularity sort' do @@ -606,4 +607,24 @@ describe GroupsController do end end end + + context 'token authentication' do + it_behaves_like 'authenticates sessionless user', :show, :atom, public: true do + before do + default_params.merge!(id: group) + end + end + + it_behaves_like 'authenticates sessionless user', :issues, :atom, public: true do + before do + default_params.merge!(id: group, author_id: user.id) + end + end + + it_behaves_like 'authenticates sessionless user', :issues_calendar, :ics, public: true do + before do + default_params.merge!(id: group) + end + end + end end diff --git a/spec/controllers/import/bitbucket_server_controller_spec.rb b/spec/controllers/import/bitbucket_server_controller_spec.rb index 77060fdc3be..db912641894 100644 --- a/spec/controllers/import/bitbucket_server_controller_spec.rb +++ b/spec/controllers/import/bitbucket_server_controller_spec.rb @@ -126,7 +126,7 @@ describe Import::BitbucketServerController do end it 'assigns repository categories' do - created_project = create(:project, import_type: 'bitbucket_server', creator_id: user.id, import_status: 'finished', import_source: @created_repo.browse_url) + created_project = create(:project, :import_finished, import_type: 'bitbucket_server', creator_id: user.id, import_source: @created_repo.browse_url) repos = instance_double(BitbucketServer::Collection) expect(repos).to receive(:partition).and_return([[@repo, @created_repo], [@invalid_repo]]) diff --git a/spec/controllers/oauth/applications_controller_spec.rb b/spec/controllers/oauth/applications_controller_spec.rb index ace8a954e92..b4219856fc0 100644 --- a/spec/controllers/oauth/applications_controller_spec.rb +++ b/spec/controllers/oauth/applications_controller_spec.rb @@ -40,6 +40,23 @@ describe Oauth::ApplicationsController do expect(response).to have_gitlab_http_status(302) expect(response).to redirect_to(profile_path) end + + context 'redirect_uri' do + render_views + + it 'shows an error for a forbidden URI' do + invalid_uri_params = { + doorkeeper_application: { + name: 'foo', + redirect_uri: 'javascript://alert()' + } + } + + post :create, invalid_uri_params + + expect(response.body).to include 'Redirect URI is forbidden by the server' + end + end end end diff --git a/spec/controllers/projects/avatars_controller_spec.rb b/spec/controllers/projects/avatars_controller_spec.rb index 14059cff74c..5a77a7ac06f 100644 --- a/spec/controllers/projects/avatars_controller_spec.rb +++ b/spec/controllers/projects/avatars_controller_spec.rb @@ -26,12 +26,37 @@ describe Projects::AvatarsController do context 'when the avatar is stored in the repository' do let(:filepath) { 'files/images/logo-white.png' } - it 'sends the avatar' do - subject + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + end - expect(response).to have_gitlab_http_status(200) - expect(response.header['Content-Type']).to eq('image/png') - expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + context 'enabled' do + let(:flag_value) { true } + + it 'sends the avatar' do + subject + + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Disposition']).to eq('inline') + expect(response.header['Content-Type']).to eq 'image/png' + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'sends the avatar' do + subject + + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Type']).to eq('image/png') + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + end + end end end diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 3c5a21c47fa..9fc6af6a045 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -35,6 +35,11 @@ describe Projects::BlobController do let(:id) { 'binary-encoding/encoding/binary-1.bin' } it { is_expected.to respond_with(:success) } end + + context "Markdown file" do + let(:id) { 'master/README.md' } + it { is_expected.to respond_with(:success) } + end end context 'with file path and JSON format' do diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb index a43bdd3ea80..5c72dab698c 100644 --- a/spec/controllers/projects/commits_controller_spec.rb +++ b/spec/controllers/projects/commits_controller_spec.rb @@ -5,87 +5,115 @@ describe Projects::CommitsController do let(:user) { create(:user) } before do - sign_in(user) project.add_maintainer(user) end - describe "GET commits_root" do - context "no ref is provided" do - it 'should redirect to the default branch of the project' do - get(:commits_root, - namespace_id: project.namespace, - project_id: project) + context 'signed in' do + before do + sign_in(user) + end + + describe "GET commits_root" do + context "no ref is provided" do + it 'should redirect to the default branch of the project' do + get(:commits_root, + namespace_id: project.namespace, + project_id: project) - expect(response).to redirect_to project_commits_path(project) + expect(response).to redirect_to project_commits_path(project) + end end end - end - describe "GET show" do - render_views + describe "GET show" do + render_views - context 'with file path' do - before do - get(:show, - namespace_id: project.namespace, - project_id: project, - id: id) - end + context 'with file path' do + before do + get(:show, + namespace_id: project.namespace, + project_id: project, + id: id) + end - context "valid branch, valid file" do - let(:id) { 'master/README.md' } + context "valid branch, valid file" do + let(:id) { 'master/README.md' } - it { is_expected.to respond_with(:success) } - end + it { is_expected.to respond_with(:success) } + end - context "valid branch, invalid file" do - let(:id) { 'master/invalid-path.rb' } + context "valid branch, invalid file" do + let(:id) { 'master/invalid-path.rb' } - it { is_expected.to respond_with(:not_found) } - end + it { is_expected.to respond_with(:not_found) } + end - context "invalid branch, valid file" do - let(:id) { 'invalid-branch/README.md' } + context "invalid branch, valid file" do + let(:id) { 'invalid-branch/README.md' } - it { is_expected.to respond_with(:not_found) } + it { is_expected.to respond_with(:not_found) } + end end - end - context "when the ref name ends in .atom" do - context "when the ref does not exist with the suffix" do - before do - get(:show, - namespace_id: project.namespace, - project_id: project, - id: "master.atom") + context "when the ref name ends in .atom" do + context "when the ref does not exist with the suffix" do + before do + get(:show, + namespace_id: project.namespace, + project_id: project, + id: "master.atom") + end + + it "renders as atom" do + expect(response).to be_success + expect(response.content_type).to eq('application/atom+xml') + end + + it 'renders summary with type=html' do + expect(response.body).to include('<summary type="html">') + end end - it "renders as atom" do - expect(response).to be_success - expect(response.content_type).to eq('application/atom+xml') - end + context "when the ref exists with the suffix" do + before do + commit = project.repository.commit('master') - it 'renders summary with type=html' do - expect(response.body).to include('<summary type="html">') + allow_any_instance_of(Repository).to receive(:commit).and_call_original + allow_any_instance_of(Repository).to receive(:commit).with('master.atom').and_return(commit) + + get(:show, + namespace_id: project.namespace, + project_id: project, + id: "master.atom") + end + + it "renders as HTML" do + expect(response).to be_success + expect(response.content_type).to eq('text/html') + end end end + end + end - context "when the ref exists with the suffix" do + context 'token authentication' do + context 'public project' do + it_behaves_like 'authenticates sessionless user', :show, :atom, public: true do before do - commit = project.repository.commit('master') + public_project = create(:project, :repository, :public) - allow_any_instance_of(Repository).to receive(:commit).and_call_original - allow_any_instance_of(Repository).to receive(:commit).with('master.atom').and_return(commit) - - get(:show, - namespace_id: project.namespace, - project_id: project, - id: "master.atom") + default_params.merge!(namespace_id: public_project.namespace, project_id: public_project, id: "master.atom") end + end + end + + context 'private project' do + it_behaves_like 'authenticates sessionless user', :show, :atom, public: false do + before do + private_project = create(:project, :repository, :private) + private_project.add_maintainer(user) - it "renders as HTML" do - expect(response).to be_success - expect(response.content_type).to eq('text/html') + default_params.merge!(namespace_id: private_project.namespace, project_id: private_project, id: "master.atom") end end end diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index bc17331f531..5fa0488014f 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -217,7 +217,10 @@ describe Projects::EnvironmentsController do end it 'loads the terminals for the environment' do - expect_any_instance_of(Environment).to receive(:terminals) + # In EE we have to stub EE::Environment since it overwrites the + # "terminals" method. + expect_any_instance_of(defined?(EE) ? EE::Environment : Environment) + .to receive(:terminals) get :terminal, environment_params end @@ -240,7 +243,9 @@ describe Projects::EnvironmentsController do context 'and valid id' do it 'returns the first terminal for the environment' do - expect_any_instance_of(Environment) + # In EE we have to stub EE::Environment since it overwrites the + # "terminals" method. + expect_any_instance_of(defined?(EE) ? EE::Environment : Environment) .to receive(:terminals) .and_return([:fake_terminal]) diff --git a/spec/controllers/projects/imports_controller_spec.rb b/spec/controllers/projects/imports_controller_spec.rb index adf3c78ae51..cdc63f5aab3 100644 --- a/spec/controllers/projects/imports_controller_spec.rb +++ b/spec/controllers/projects/imports_controller_spec.rb @@ -26,10 +26,11 @@ describe Projects::ImportsController do context 'when repository exists' do let(:project) { create(:project_empty_repo, import_url: 'https://github.com/vim/vim.git') } + let(:import_state) { project.import_state } context 'when import is in progress' do before do - project.update(import_status: :started) + import_state.update(status: :started) end it 'renders template' do @@ -47,7 +48,7 @@ describe Projects::ImportsController do context 'when import failed' do before do - project.update(import_status: :failed) + import_state.update(status: :failed) end it 'redirects to new_namespace_project_import_path' do @@ -59,7 +60,7 @@ describe Projects::ImportsController do context 'when import finished' do before do - project.update(import_status: :finished) + import_state.update(status: :finished) end context 'when project is a fork' do @@ -108,7 +109,7 @@ describe Projects::ImportsController do context 'when import never happened' do before do - project.update(import_status: :none) + import_state.update(status: :none) end it 'redirects to namespace_project_path' do diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 80138183c07..02930edbf72 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1068,4 +1068,40 @@ describe Projects::IssuesController do end end end + + context 'private project with token authentication' do + let(:private_project) { create(:project, :private) } + + it_behaves_like 'authenticates sessionless user', :index, :atom do + before do + default_params.merge!(project_id: private_project, namespace_id: private_project.namespace) + + private_project.add_maintainer(user) + end + end + + it_behaves_like 'authenticates sessionless user', :calendar, :ics do + before do + default_params.merge!(project_id: private_project, namespace_id: private_project.namespace) + + private_project.add_maintainer(user) + end + end + end + + context 'public project with token authentication' do + let(:public_project) { create(:project, :public) } + + it_behaves_like 'authenticates sessionless user', :index, :atom, public: true do + before do + default_params.merge!(project_id: public_project, namespace_id: public_project.namespace) + end + end + + it_behaves_like 'authenticates sessionless user', :calendar, :ics, public: true do + before do + default_params.merge!(project_id: public_project, namespace_id: public_project.namespace) + end + end + end end diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index bd193979192..fca313dafb1 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -876,23 +876,48 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do context "when job has a trace artifact" do let(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) } - it 'returns a trace' do - response = subject + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + end - expect(response).to have_gitlab_http_status(:ok) - expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") - expect(response.body).to eq(job.job_artifacts_trace.open.read) + context 'enabled' do + let(:flag_value) { true } + + it "sets #{Gitlab::Workhorse::DETECT_HEADER} header" do + response = subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") + expect(response.body).to eq(job.job_artifacts_trace.open.read) + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'returns a trace' do + response = subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") + expect(response.body).to eq(job.job_artifacts_trace.open.read) + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to be nil + end + end end end context "when job has a trace file" do let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) } - it "send a trace file" do + it 'sends a trace file' do response = subject expect(response).to have_gitlab_http_status(:ok) expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") + expect(response.headers["Content-Disposition"]).to match(/^inline/) expect(response.body).to eq("BUILD TRACE") end end @@ -904,12 +929,27 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do job.update_column(:trace, "Sample trace") end - it "send a trace file" do + it 'sends a trace file' do response = subject expect(response).to have_gitlab_http_status(:ok) - expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") - expect(response.body).to eq("Sample trace") + expect(response.headers['Content-Type']).to eq('text/plain; charset=utf-8') + expect(response.headers['Content-Disposition']).to match(/^inline/) + expect(response.body).to eq('Sample trace') + end + + context 'when trace format is not text/plain' do + before do + job.update_column(:trace, '<html></html>') + end + + it 'sets content disposition to attachment' do + response = subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Content-Type']).to eq('text/plain; charset=utf-8') + expect(response.headers['Content-Disposition']).to match(/^attachment/) + end end end diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb index 9dc06436c72..8fc5d302af6 100644 --- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb @@ -36,6 +36,18 @@ describe Projects::MergeRequests::DiffsController do end end + context 'when note has no position' do + before do + create(:legacy_diff_note_on_merge_request, project: project, noteable: merge_request, position: nil) + end + + it 'serializes merge request diff collection' do + expect_any_instance_of(DiffsSerializer).to receive(:represent).with(an_instance_of(Gitlab::Diff::FileCollection::MergeRequestDiff), an_instance_of(Hash)) + + go + end + end + context 'with forked projects with submodules' do render_views diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index e62523c65c9..7f15da859e5 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -290,6 +290,20 @@ describe Projects::MergeRequestsController do it_behaves_like 'update invalid issuable', MergeRequest end + + context 'two merge requests with the same source branch' do + it 'does not allow a closed merge request to be reopened if another one is open' do + merge_request.close! + create(:merge_request, source_project: merge_request.source_project, source_branch: merge_request.source_branch) + + update_merge_request(state_event: 'reopen') + + errors = assigns[:merge_request].errors + + expect(errors[:validate_branches]).to include(/Another open merge request already exists for this source branch/) + expect(merge_request.reload).to be_closed + end + end end describe 'POST merge' do diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index ccd4fc4db3a..658aa2a6738 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -143,11 +143,27 @@ describe Projects::MilestonesController do end describe '#promote' do + let(:group) { create(:group) } + + before do + project.update(namespace: group) + end + + context 'when user does not have permission to promote milestone' do + before do + group.add_guest(user) + end + + it 'renders 404' do + post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid + + expect(response).to have_gitlab_http_status(404) + end + end + context 'promotion succeeds' do before do - group = create(:group) group.add_developer(user) - milestone.project.update(namespace: group) end it 'shows group milestone' do @@ -166,12 +182,17 @@ describe Projects::MilestonesController do end end - context 'promotion fails' do - it 'shows project milestone' do + context 'when user cannot admin group milestones' do + before do + project.add_developer(user) + end + + it 'renders 404' do + project.update(namespace: user.namespace) + post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid - expect(response).to redirect_to(project_milestone_path(project, milestone)) - expect(flash[:alert]).to eq('Promotion failed - Project does not belong to a group.') + expect(response).to have_gitlab_http_status(404) end end end diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 9ac7b8ee8a8..d2a26068362 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -283,14 +283,14 @@ describe Projects::NotesController do def post_create(extra_params = {}) post :create, { - note: { note: 'some other note' }, - namespace_id: project.namespace, - project_id: project, - target_type: 'merge_request', - target_id: merge_request.id, - note_project_id: forked_project.id, - in_reply_to_discussion_id: existing_comment.discussion_id - }.merge(extra_params) + note: { note: 'some other note', noteable_id: merge_request.id }, + namespace_id: project.namespace, + project_id: project, + target_type: 'merge_request', + target_id: merge_request.id, + note_project_id: forked_project.id, + in_reply_to_discussion_id: existing_comment.discussion_id + }.merge(extra_params) end context 'when the note_project_id is not correct' do @@ -324,6 +324,30 @@ describe Projects::NotesController do end end + context 'when target_id and noteable_id do not match' do + let(:locked_issue) { create(:issue, :locked, project: project) } + let(:issue) {create(:issue, project: project)} + + before do + project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) + project.project_member(user).destroy + end + + it 'uses target_id and ignores noteable_id' do + request_params = { + note: { note: 'some note', noteable_type: 'Issue', noteable_id: locked_issue.id }, + target_type: 'issue', + target_id: issue.id, + project_id: project, + namespace_id: project.namespace + } + + expect { post :create, request_params }.to change { issue.notes.count }.by(1) + .and change { locked_issue.notes.count }.by(0) + expect(response).to have_gitlab_http_status(302) + end + end + context 'when the merge request discussion is locked' do before do project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) @@ -376,35 +400,60 @@ describe Projects::NotesController do end describe 'PUT update' do - let(:request_params) do - { - namespace_id: project.namespace, - project_id: project, - id: note, - format: :json, - note: { - note: "New comment" + context "should update the note with a valid issue" do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + id: note, + format: :json, + note: { + note: "New comment" + } } - } - end + end - before do - sign_in(note.author) - project.add_developer(note.author) + before do + sign_in(note.author) + project.add_developer(note.author) + end + + it "updates the note" do + expect { put :update, request_params }.to change { note.reload.note } + end end + context "doesnt update the note" do + let(:issue) { create(:issue, :confidential, project: project) } + let(:note) { create(:note, noteable: issue, project: project) } - it "updates the note" do - expect { put :update, request_params }.to change { note.reload.note } + before do + sign_in(user) + project.add_guest(user) + end + + it "disallows edits when the issue is confidential and the user has guest permissions" do + request_params = { + namespace_id: project.namespace, + project_id: project, + id: note, + format: :json, + note: { + note: "New comment" + } + } + expect { put :update, request_params }.not_to change { note.reload.note } + expect(response).to have_gitlab_http_status(404) + end end end describe 'DELETE destroy' do let(:request_params) do { - namespace_id: project.namespace, - project_id: project, - id: note, - format: :js + namespace_id: project.namespace, + project_id: project, + id: note, + format: :js } end diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb index 6b658bf5295..d3cd15fbcd7 100644 --- a/spec/controllers/projects/raw_controller_spec.rb +++ b/spec/controllers/projects/raw_controller_spec.rb @@ -14,26 +14,74 @@ describe Projects::RawController do context 'regular filename' do let(:filepath) { 'master/README.md' } - it 'delivers ASCII file' do - subject - - expect(response).to have_gitlab_http_status(200) - expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') - expect(response.header['Content-Disposition']) - .to eq('inline') - expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + + subject + end + + context 'enabled' do + let(:flag_value) { true } + + it 'delivers ASCII file' do + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') + expect(response.header['Content-Disposition']).to eq('inline') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'delivers ASCII file' do + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') + expect(response.header['Content-Disposition']).to eq('inline') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + end + end end end context 'image header' do let(:filepath) { 'master/files/images/6049019_460s.jpg' } - it 'sets image content type header' do - subject + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + end + + context 'enabled' do + let(:flag_value) { true } + + it 'leaves image content disposition' do + subject + + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Type']).to eq('image/jpeg') + expect(response.header['Content-Disposition']).to eq('inline') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'sets image content type header' do + subject - expect(response).to have_gitlab_http_status(200) - expect(response.header['Content-Type']).to eq('image/jpeg') - expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Type']).to eq('image/jpeg') + expect(response.header['Content-Disposition']).to eq('inline') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + end + end end end diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb new file mode 100644 index 00000000000..284b582b1f5 --- /dev/null +++ b/spec/controllers/projects/serverless/functions_controller_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::Serverless::FunctionsController do + include KubernetesHelpers + include ReactiveCachingHelpers + + let(:user) { create(:user) } + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } + let(:service) { cluster.platform_kubernetes } + let(:project) { cluster.project} + + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: cluster.cluster_project.project) + end + + before do + project.add_maintainer(user) + sign_in(user) + end + + def params(opts = {}) + opts.reverse_merge(namespace_id: project.namespace.to_param, + project_id: project.to_param) + end + + describe 'GET #index' do + context 'empty cache' do + it 'has no data' do + get :index, params({ format: :json }) + + expect(response).to have_gitlab_http_status(204) + end + + it 'renders an html page' do + get :index, params + + expect(response).to have_gitlab_http_status(200) + end + end + end + + describe 'GET #index with data', :use_clean_rails_memory_store_caching do + before do + stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"]) + end + + it 'has data' do + get :index, params({ format: :json }) + + expect(response).to have_gitlab_http_status(200) + + expect(json_response).to contain_exactly( + a_hash_including( + "name" => project.name, + "url" => "http://#{project.name}.#{namespace.namespace}.example.com" + ) + ) + end + + it 'has data in html' do + get :index, params + + expect(response).to have_gitlab_http_status(200) + end + end +end diff --git a/spec/controllers/projects/settings/repository_controller_spec.rb b/spec/controllers/projects/settings/repository_controller_spec.rb index 9cee40b7553..70f79a47e63 100644 --- a/spec/controllers/projects/settings/repository_controller_spec.rb +++ b/spec/controllers/projects/settings/repository_controller_spec.rb @@ -17,4 +17,37 @@ describe Projects::Settings::RepositoryController do expect(response).to render_template(:show) end end + + describe 'PUT cleanup' do + before do + allow(RepositoryCleanupWorker).to receive(:perform_async) + end + + def do_put! + object_map = fixture_file_upload('spec/fixtures/bfg_object_map.txt') + + put :cleanup, namespace_id: project.namespace, project_id: project, project: { object_map: object_map } + end + + context 'feature enabled' do + it 'enqueues a RepositoryCleanupWorker' do + stub_feature_flags(project_cleanup: true) + + do_put! + + expect(response).to redirect_to project_settings_repository_path(project) + expect(RepositoryCleanupWorker).to have_received(:perform_async).once + end + end + + context 'feature disabled' do + it 'shows a 404 error' do + stub_feature_flags(project_cleanup: false) + + do_put! + + expect(response).to have_gitlab_http_status(404) + end + end + end end diff --git a/spec/controllers/projects/tags_controller_spec.rb b/spec/controllers/projects/tags_controller_spec.rb index c48f41ca12e..6fbf75d0259 100644 --- a/spec/controllers/projects/tags_controller_spec.rb +++ b/spec/controllers/projects/tags_controller_spec.rb @@ -35,4 +35,26 @@ describe Projects::TagsController do it { is_expected.to respond_with(:not_found) } end end + + context 'private project with token authentication' do + let(:private_project) { create(:project, :repository, :private) } + + it_behaves_like 'authenticates sessionless user', :index, :atom do + before do + default_params.merge!(project_id: private_project, namespace_id: private_project.namespace) + + private_project.add_maintainer(user) + end + end + end + + context 'public project with token authentication' do + let(:public_project) { create(:project, :repository, :public) } + + it_behaves_like 'authenticates sessionless user', :index, :atom, public: true do + before do + default_params.merge!(project_id: public_project, namespace_id: public_project.namespace) + end + end + end end diff --git a/spec/controllers/projects/wikis_controller_spec.rb b/spec/controllers/projects/wikis_controller_spec.rb index 6d75152857b..b974d927856 100644 --- a/spec/controllers/projects/wikis_controller_spec.rb +++ b/spec/controllers/projects/wikis_controller_spec.rb @@ -52,24 +52,56 @@ describe Projects::WikisController do let(:path) { upload_file_to_wiki(project, user, file_name) } - before do - subject - end - subject { get :show, namespace_id: project.namespace, project_id: project, id: path } context 'when file is an image' do let(:file_name) { 'dk.png' } - it 'renders the content inline' do - expect(response.headers['Content-Disposition']).to match(/^inline/) - end + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + + subject + end - context 'when file is a svg' do - let(:file_name) { 'unsanitized.svg' } + context 'enabled' do + let(:flag_value) { true } - it 'renders the content as an attachment' do - expect(response.headers['Content-Disposition']).to match(/^attachment/) + it 'delivers the image' do + expect(response.headers['Content-Type']).to eq('image/png') + expect(response.headers['Content-Disposition']).to match(/^inline/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + + context 'when file is a svg' do + let(:file_name) { 'unsanitized.svg' } + + it 'delivers the image' do + expect(response.headers['Content-Type']).to eq('image/svg+xml') + expect(response.headers['Content-Disposition']).to match(/^attachment/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'renders the content inline' do + expect(response.headers['Content-Type']).to eq('image/png') + expect(response.headers['Content-Disposition']).to match(/^inline/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + end + + context 'when file is a svg' do + let(:file_name) { 'unsanitized.svg' } + + it 'renders the content as an attachment' do + expect(response.headers['Content-Type']).to eq('image/svg+xml') + expect(response.headers['Content-Disposition']).to match(/^attachment/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + end + end end end end @@ -77,8 +109,32 @@ describe Projects::WikisController do context 'when file is a pdf' do let(:file_name) { 'git-cheat-sheet.pdf' } - it 'sets the content type to application/octet-stream' do - expect(response.headers['Content-Type']).to eq 'application/octet-stream' + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + + subject + end + + context 'enabled' do + let(:flag_value) { true } + + it 'sets the content type to sets the content response headers' do + expect(response.headers['Content-Type']).to eq 'application/octet-stream' + expect(response.headers['Content-Disposition']).to match(/^inline/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'sets the content response headers' do + expect(response.headers['Content-Type']).to eq 'application/octet-stream' + expect(response.headers['Content-Disposition']).to match(/^inline/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + end + end end end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 3bc9cbe64c5..7849bec4762 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -882,6 +882,28 @@ describe ProjectsController do end end + context 'private project with token authentication' do + let(:private_project) { create(:project, :private) } + + it_behaves_like 'authenticates sessionless user', :show, :atom do + before do + default_params.merge!(id: private_project, namespace_id: private_project.namespace) + + private_project.add_maintainer(user) + end + end + end + + context 'public project with token authentication' do + let(:public_project) { create(:project, :public) } + + it_behaves_like 'authenticates sessionless user', :show, :atom, public: true do + before do + default_params.merge!(id: public_project, namespace_id: public_project.namespace) + end + end + end + def project_moved_message(redirect_route, project) "Project '#{redirect_route.path}' was moved to '#{project.full_path}'. Please update any links and bookmarks that may still have the old path." end diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index 9effe47ab05..957bab638b1 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -437,7 +437,10 @@ describe SnippetsController do end context 'when signed in user is the author' do + let(:flag_value) { false } + before do + stub_feature_flags(workhorse_set_content_type: flag_value) get :raw, id: personal_snippet.to_param end @@ -451,6 +454,24 @@ describe SnippetsController do expect(response.header['Content-Disposition']).to match(/inline/) end + + context 'when feature flag workhorse_set_content_type is' do + context 'enabled' do + let(:flag_value) { true } + + it "sets #{Gitlab::Workhorse::DETECT_HEADER} header" do + expect(response).to have_gitlab_http_status(200) + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + end + + context 'disabled' do + it "does not set #{Gitlab::Workhorse::DETECT_HEADER} header" do + expect(response).to have_gitlab_http_status(200) + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to be nil + end + end + end end end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 071f96a729e..fe438e71e9e 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -395,6 +395,14 @@ describe UsersController do end end + context 'token authentication' do + it_behaves_like 'authenticates sessionless user', :show, :atom, public: true do + before do + default_params.merge!(username: user.username) + end + end + end + def user_moved_message(redirect_route, user) "User '#{redirect_route.path}' was moved to '#{user.full_path}'. Please update any links and bookmarks that may still have the old path." end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 90754319f05..07c1fc31152 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -308,7 +308,7 @@ FactoryBot.define do trait :with_runner_session do after(:build) do |build| - build.build_runner_session(url: 'ws://localhost') + build.build_runner_session(url: 'https://localhost') end end end diff --git a/spec/factories/clusters/kubernetes_namespaces.rb b/spec/factories/clusters/kubernetes_namespaces.rb index 6ad93fb0f45..3b50a57433f 100644 --- a/spec/factories/clusters/kubernetes_namespaces.rb +++ b/spec/factories/clusters/kubernetes_namespaces.rb @@ -5,10 +5,12 @@ FactoryBot.define do association :cluster, :project, :provided_by_gcp after(:build) do |kubernetes_namespace| - cluster_project = kubernetes_namespace.cluster.cluster_project + if kubernetes_namespace.cluster.project_type? + cluster_project = kubernetes_namespace.cluster.cluster_project - kubernetes_namespace.project = cluster_project.project - kubernetes_namespace.cluster_project = cluster_project + kubernetes_namespace.project = cluster_project.project + kubernetes_namespace.cluster_project = cluster_project + end end trait :with_token do diff --git a/spec/factories/import_state.rb b/spec/factories/import_state.rb index 15d0a9d466a..d6de26dccbc 100644 --- a/spec/factories/import_state.rb +++ b/spec/factories/import_state.rb @@ -5,6 +5,7 @@ FactoryBot.define do transient do import_url { generate(:url) } + import_type nil end trait :repository do @@ -32,7 +33,11 @@ FactoryBot.define do end after(:create) do |import_state, evaluator| - import_state.project.update_columns(import_url: evaluator.import_url) + columns = {} + columns[:import_url] = evaluator.import_url unless evaluator.import_url.blank? + columns[:import_type] = evaluator.import_type unless evaluator.import_type.blank? + + import_state.project.update_columns(columns) end end end diff --git a/spec/factories/pool_repositories.rb b/spec/factories/pool_repositories.rb new file mode 100644 index 00000000000..2ed0844ed47 --- /dev/null +++ b/spec/factories/pool_repositories.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :pool_repository do + shard + end +end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index e4823a5adf1..1906c06a211 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -30,6 +30,8 @@ FactoryBot.define do # we can't assign the delegated `#ci_cd_settings` attributes directly, as the # `#ci_cd_settings` relation needs to be created first group_runners_enabled nil + import_status nil + import_jid nil end after(:create) do |project, evaluator| @@ -64,6 +66,13 @@ FactoryBot.define do # assign the delegated `#ci_cd_settings` attributes after create project.reload.group_runners_enabled = evaluator.group_runners_enabled unless evaluator.group_runners_enabled.nil? + + if evaluator.import_status + import_state = project.import_state || project.build_import_state + import_state.status = evaluator.import_status + import_state.jid = evaluator.import_jid + import_state.save + end end trait :public do diff --git a/spec/factories/shards.rb b/spec/factories/shards.rb new file mode 100644 index 00000000000..c095fa5f0a0 --- /dev/null +++ b/spec/factories/shards.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :shard do + name "default" + end +end diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index f7c7a257075..931095936a6 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe "Admin::Users" do + include Spec::Support::Helpers::Features::ListRowsHelpers + let!(:user) do create(:omniauth_user, provider: 'twitter', extern_uid: '123456') end @@ -30,6 +32,51 @@ describe "Admin::Users" do expect(page).to have_button('Delete user and contributions') end + describe 'search and sort' do + before do + create(:user, name: 'Foo Bar') + create(:user, name: 'Foo Baz') + create(:user, name: 'Dmitriy') + end + + it 'searches users by name' do + visit admin_users_path(search_query: 'Foo') + + expect(page).to have_content('Foo Bar') + expect(page).to have_content('Foo Baz') + expect(page).not_to have_content('Dmitriy') + end + + it 'sorts users by name' do + visit admin_users_path + + sort_by('Name') + + expect(first_row.text).to include('Dmitriy') + expect(second_row.text).to include('Foo Bar') + end + + it 'sorts search results only' do + visit admin_users_path(search_query: 'Foo') + + sort_by('Name') + + expect(page).not_to have_content('Dmitriy') + expect(first_row.text).to include('Foo Bar') + expect(second_row.text).to include('Foo Baz') + end + + it 'searches with respect of sorting' do + visit admin_users_path(sort: 'Name') + + fill_in :search_query, with: 'Foo' + click_button('Search users') + + expect(first_row.text).to include('Foo Bar') + expect(second_row.text).to include('Foo Baz') + end + end + describe 'Two-factor Authentication filters' do it 'counts users who have enabled 2FA' do create(:user, :two_factor) @@ -205,75 +252,118 @@ describe "Admin::Users" do describe 'Impersonation' do let(:another_user) { create(:user) } - before do - visit admin_user_path(another_user) - end - context 'before impersonating' do - it 'shows impersonate button for other users' do - expect(page).to have_content('Impersonate') + subject { visit admin_user_path(user_to_visit) } + + let(:user_to_visit) { another_user } + + context 'for other users' do + it 'shows impersonate button for other users' do + subject + + expect(page).to have_content('Impersonate') + end end - it 'does not show impersonate button for admin itself' do - visit admin_user_path(current_user) + context 'for admin itself' do + let(:user_to_visit) { current_user } + + it 'does not show impersonate button for admin itself' do + subject - expect(page).not_to have_content('Impersonate') + expect(page).not_to have_content('Impersonate') + end end - it 'does not show impersonate button for blocked user' do - another_user.block + context 'for blocked user' do + before do + another_user.block + end - visit admin_user_path(another_user) + it 'does not show impersonate button for blocked user' do + subject + + expect(page).not_to have_content('Impersonate') + end + end + + context 'when impersonation is disabled' do + before do + stub_config_setting(impersonation_enabled: false) + end - expect(page).not_to have_content('Impersonate') + it 'does not show impersonate button' do + subject - another_user.activate + expect(page).not_to have_content('Impersonate') + end end end context 'when impersonating' do + subject { click_link 'Impersonate' } + before do - click_link 'Impersonate' + visit admin_user_path(another_user) end it 'logs in as the user when impersonate is clicked' do + subject + expect(page.find(:css, '.header-user .profile-link')['data-user']).to eql(another_user.username) end it 'sees impersonation log out icon' do - icon = first('.fa.fa-user-secret') + subject + icon = first('.fa.fa-user-secret') expect(icon).not_to be nil end - it 'logs out of impersonated user back to original user' do - find(:css, 'li.impersonation a').click - - expect(page.find(:css, '.header-user .profile-link')['data-user']).to eq(current_user.username) - end + context 'a user with an expired password' do + before do + another_user.update(password_expires_at: Time.now - 5.minutes) + end - it 'is redirected back to the impersonated users page in the admin after stopping' do - find(:css, 'li.impersonation a').click + it 'does not redirect to password change page' do + subject - expect(current_path).to eq("/admin/users/#{another_user.username}") + expect(current_path).to eq('/') + end end end - context 'when impersonating a user with an expired password' do + context 'ending impersonation' do + subject { find(:css, 'li.impersonation a').click } + before do - another_user.update(password_expires_at: Time.now - 5.minutes) + visit admin_user_path(another_user) click_link 'Impersonate' end - it 'does not redirect to password change page' do - expect(current_path).to eq('/') + it 'logs out of impersonated user back to original user' do + subject + + expect(page.find(:css, '.header-user .profile-link')['data-user']).to eq(current_user.username) end it 'is redirected back to the impersonated users page in the admin after stopping' do - find(:css, 'li.impersonation a').click + subject expect(current_path).to eq("/admin/users/#{another_user.username}") end + + context 'a user with an expired password' do + before do + another_user.update(password_expires_at: Time.now - 5.minutes) + end + + it 'is redirected back to the impersonated users page in the admin after stopping' do + subject + + expect(current_path).to eq("/admin/users/#{another_user.username}") + end + end end end @@ -523,4 +613,10 @@ describe "Admin::Users" do def check_breadcrumb(content) expect(find('.breadcrumbs-sub-title')).to have_content(content) end + + def sort_by(text) + page.within('.user-sort-dropdown') do + click_link text + end + end end diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index 2cdd3f55b50..d96707e55fd 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -176,7 +176,7 @@ describe 'Issue Boards add issue modal filtering', :js do it 'filters by no label' do set_filter('label') - click_filter_link('No Label') + click_filter_link('None') submit_filter page.within('.add-issues-modal') do diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index 9ffa75aee47..282bf542e77 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -6,7 +6,6 @@ describe 'Dashboard Merge Requests' do include ProjectForksHelper let(:current_user) { create :user } - let(:user) { current_user } let(:project) { create(:project) } let(:public_project) { create(:project, :public, :repository) } diff --git a/spec/features/groups/members/list_members_spec.rb b/spec/features/groups/members/list_members_spec.rb index e1587a8b6a5..4ba7161601e 100644 --- a/spec/features/groups/members/list_members_spec.rb +++ b/spec/features/groups/members/list_members_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe 'Groups > Members > List members' do include Select2Helper + include Spec::Support::Helpers::Features::ListRowsHelpers let(:user1) { create(:user, name: 'John Doe') } let(:user2) { create(:user, name: 'Mary Jane') } @@ -43,12 +44,4 @@ describe 'Groups > Members > List members' do let(:user_with_status) { user2 } end end - - def first_row - page.all('ul.content-list > li')[0] - end - - def second_row - page.all('ul.content-list > li')[1] - end end diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb index 0eda2c7f26d..e2b4a491a13 100644 --- a/spec/features/groups/members/manage_members_spec.rb +++ b/spec/features/groups/members/manage_members_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe 'Groups > Members > Manage members' do include Select2Helper + include Spec::Support::Helpers::Features::ListRowsHelpers let(:user1) { create(:user, name: 'John Doe') } let(:user2) { create(:user, name: 'Mary Jane') } @@ -119,14 +120,6 @@ describe 'Groups > Members > Manage members' do end end - def first_row - page.all('ul.content-list > li')[0] - end - - def second_row - page.all('ul.content-list > li')[1] - end - def add_user(id, role) page.within ".users-group-form" do select2(id, from: "#user_ids", multiple: true) diff --git a/spec/features/ide_spec.rb b/spec/features/ide_spec.rb index 65989c36c1e..6eb59ef72c2 100644 --- a/spec/features/ide_spec.rb +++ b/spec/features/ide_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe 'IDE', :js do - describe 'sub-groups' do + describe 'sub-groups', :nested_groups do let(:user) { create(:user) } let(:group) { create(:group) } let(:subgroup) { create(:group, parent: group) } diff --git a/spec/features/issuables/default_sort_order_spec.rb b/spec/features/issuables/default_sort_order_spec.rb deleted file mode 100644 index caee7a67aec..00000000000 --- a/spec/features/issuables/default_sort_order_spec.rb +++ /dev/null @@ -1,179 +0,0 @@ -require 'spec_helper' - -describe 'Projects > Issuables > Default sort order' do - let(:project) { create(:project, :public) } - - let(:first_created_issuable) { issuables.order_created_asc.first } - let(:last_created_issuable) { issuables.order_created_desc.first } - - let(:first_updated_issuable) { issuables.order_updated_asc.first } - let(:last_updated_issuable) { issuables.order_updated_desc.first } - - context 'for merge requests' do - include MergeRequestHelpers - - let!(:issuables) do - timestamps = [{ created_at: 3.minutes.ago, updated_at: 20.seconds.ago }, - { created_at: 2.minutes.ago, updated_at: 30.seconds.ago }, - { created_at: 4.minutes.ago, updated_at: 10.seconds.ago }] - - timestamps.each_with_index do |ts, i| - create issuable_type, { title: "#{issuable_type}_#{i}", - source_branch: "#{issuable_type}_#{i}", - source_project: project }.merge(ts) - end - - MergeRequest.all - end - - context 'in the "merge requests" tab', :js do - let(:issuable_type) { :merge_request } - - it 'is "last created"' do - visit_merge_requests project - - expect(first_merge_request).to include(last_created_issuable.title) - expect(last_merge_request).to include(first_created_issuable.title) - end - end - - context 'in the "merge requests / open" tab', :js do - let(:issuable_type) { :merge_request } - - it 'is "created date"' do - visit_merge_requests_with_state(project, 'open') - - expect(selected_sort_order).to eq('created date') - expect(first_merge_request).to include(last_created_issuable.title) - expect(last_merge_request).to include(first_created_issuable.title) - end - end - - context 'in the "merge requests / merged" tab', :js do - let(:issuable_type) { :merged_merge_request } - - it 'is "last updated"' do - visit_merge_requests_with_state(project, 'merged') - - expect(find('.issues-other-filters')).to have_content('Last updated') - expect(first_merge_request).to include(last_updated_issuable.title) - expect(last_merge_request).to include(first_updated_issuable.title) - end - end - - context 'in the "merge requests / closed" tab', :js do - let(:issuable_type) { :closed_merge_request } - - it 'is "last updated"' do - visit_merge_requests_with_state(project, 'closed') - - expect(find('.issues-other-filters')).to have_content('Last updated') - expect(first_merge_request).to include(last_updated_issuable.title) - expect(last_merge_request).to include(first_updated_issuable.title) - end - end - - context 'in the "merge requests / all" tab', :js do - let(:issuable_type) { :merge_request } - - it 'is "created date"' do - visit_merge_requests_with_state(project, 'all') - - expect(find('.issues-other-filters')).to have_content('Created date') - expect(first_merge_request).to include(last_created_issuable.title) - expect(last_merge_request).to include(first_created_issuable.title) - end - end - end - - context 'for issues' do - include IssueHelpers - - let!(:issuables) do - timestamps = [{ created_at: 3.minutes.ago, updated_at: 20.seconds.ago }, - { created_at: 2.minutes.ago, updated_at: 30.seconds.ago }, - { created_at: 4.minutes.ago, updated_at: 10.seconds.ago }] - - timestamps.each_with_index do |ts, i| - create issuable_type, { title: "#{issuable_type}_#{i}", - project: project }.merge(ts) - end - - Issue.all - end - - context 'in the "issues" tab', :js do - let(:issuable_type) { :issue } - - it 'is "created date"' do - visit_issues project - - expect(find('.issues-other-filters')).to have_content('Created date') - expect(first_issue).to include(last_created_issuable.title) - expect(last_issue).to include(first_created_issuable.title) - end - end - - context 'in the "issues / open" tab', :js do - let(:issuable_type) { :issue } - - it 'is "created date"' do - visit_issues_with_state(project, 'open') - - expect(find('.issues-other-filters')).to have_content('Created date') - expect(first_issue).to include(last_created_issuable.title) - expect(last_issue).to include(first_created_issuable.title) - end - end - - context 'in the "issues / closed" tab', :js do - let(:issuable_type) { :closed_issue } - - it 'is "last updated"' do - visit_issues_with_state(project, 'closed') - - expect(find('.issues-other-filters')).to have_content('Last updated') - expect(first_issue).to include(last_updated_issuable.title) - expect(last_issue).to include(first_updated_issuable.title) - end - end - - context 'in the "issues / all" tab', :js do - let(:issuable_type) { :issue } - - it 'is "created date"' do - visit_issues_with_state(project, 'all') - - expect(find('.issues-other-filters')).to have_content('Created date') - expect(first_issue).to include(last_created_issuable.title) - expect(last_issue).to include(first_created_issuable.title) - end - end - - context 'when the sort in the URL is id_desc' do - let(:issuable_type) { :issue } - - before do - visit_issues(project, sort: 'id_desc') - end - - it 'shows the sort order as created date' do - expect(find('.issues-other-filters')).to have_content('Created date') - expect(first_issue).to include(last_created_issuable.title) - expect(last_issue).to include(first_created_issuable.title) - end - end - end - - def selected_sort_order - find('.filter-dropdown-container .dropdown button').text.downcase - end - - def visit_merge_requests_with_state(project, state) - visit_merge_requests project, state: state - end - - def visit_issues_with_state(project, state) - visit_issues project, state: state - end -end diff --git a/spec/features/issuables/sorting_list_spec.rb b/spec/features/issuables/sorting_list_spec.rb new file mode 100644 index 00000000000..0601dd47c03 --- /dev/null +++ b/spec/features/issuables/sorting_list_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe 'Sort Issuable List' do + let(:project) { create(:project, :public) } + + let(:first_created_issuable) { issuables.order_created_asc.first } + let(:last_created_issuable) { issuables.order_created_desc.first } + + let(:first_updated_issuable) { issuables.order_updated_asc.first } + let(:last_updated_issuable) { issuables.order_updated_desc.first } + + context 'for merge requests' do + include MergeRequestHelpers + + let!(:issuables) do + timestamps = [{ created_at: 3.minutes.ago, updated_at: 20.seconds.ago }, + { created_at: 2.minutes.ago, updated_at: 30.seconds.ago }, + { created_at: 4.minutes.ago, updated_at: 10.seconds.ago }] + + timestamps.each_with_index do |ts, i| + create issuable_type, { title: "#{issuable_type}_#{i}", + source_branch: "#{issuable_type}_#{i}", + source_project: project }.merge(ts) + end + + MergeRequest.all + end + + context 'default sort order' do + context 'in the "merge requests" tab', :js do + let(:issuable_type) { :merge_request } + + it 'is "last created"' do + visit_merge_requests project + + expect(first_merge_request).to include(last_created_issuable.title) + expect(last_merge_request).to include(first_created_issuable.title) + end + end + + context 'in the "merge requests / open" tab', :js do + let(:issuable_type) { :merge_request } + + it 'is "created date"' do + visit_merge_requests_with_state(project, 'open') + + expect(selected_sort_order).to eq('created date') + expect(first_merge_request).to include(last_created_issuable.title) + expect(last_merge_request).to include(first_created_issuable.title) + end + end + + context 'in the "merge requests / merged" tab', :js do + let(:issuable_type) { :merged_merge_request } + + it 'is "last updated"' do + visit_merge_requests_with_state(project, 'merged') + + expect(find('.issues-other-filters')).to have_content('Last updated') + expect(first_merge_request).to include(last_updated_issuable.title) + expect(last_merge_request).to include(first_updated_issuable.title) + end + end + + context 'in the "merge requests / closed" tab', :js do + let(:issuable_type) { :closed_merge_request } + + it 'is "last updated"' do + visit_merge_requests_with_state(project, 'closed') + + expect(find('.issues-other-filters')).to have_content('Last updated') + expect(first_merge_request).to include(last_updated_issuable.title) + expect(last_merge_request).to include(first_updated_issuable.title) + end + end + + context 'in the "merge requests / all" tab', :js do + let(:issuable_type) { :merge_request } + + it 'is "created date"' do + visit_merge_requests_with_state(project, 'all') + + expect(find('.issues-other-filters')).to have_content('Created date') + expect(first_merge_request).to include(last_created_issuable.title) + expect(last_merge_request).to include(first_created_issuable.title) + end + end + + context 'custom sorting' do + let(:issuable_type) { :merge_request } + + it 'supports sorting in asc and desc order' do + visit_merge_requests_with_state(project, 'open') + + page.within('.issues-other-filters') do + click_button('Created date') + click_link('Last updated') + end + + expect(first_merge_request).to include(last_updated_issuable.title) + expect(last_merge_request).to include(first_updated_issuable.title) + + find('.issues-other-filters .filter-dropdown-container .qa-reverse-sort').click + + expect(first_merge_request).to include(first_updated_issuable.title) + expect(last_merge_request).to include(last_updated_issuable.title) + end + end + end + end + + context 'for issues' do + include IssueHelpers + + let!(:issuables) do + timestamps = [{ created_at: 3.minutes.ago, updated_at: 20.seconds.ago }, + { created_at: 2.minutes.ago, updated_at: 30.seconds.ago }, + { created_at: 4.minutes.ago, updated_at: 10.seconds.ago }] + + timestamps.each_with_index do |ts, i| + create issuable_type, { title: "#{issuable_type}_#{i}", + project: project }.merge(ts) + end + + Issue.all + end + + context 'default sort order' do + context 'in the "issues" tab', :js do + let(:issuable_type) { :issue } + + it 'is "created date"' do + visit_issues project + + expect(find('.issues-other-filters')).to have_content('Created date') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + + context 'in the "issues / open" tab', :js do + let(:issuable_type) { :issue } + + it 'is "created date"' do + visit_issues_with_state(project, 'open') + + expect(find('.issues-other-filters')).to have_content('Created date') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + + context 'in the "issues / closed" tab', :js do + let(:issuable_type) { :closed_issue } + + it 'is "last updated"' do + visit_issues_with_state(project, 'closed') + + expect(find('.issues-other-filters')).to have_content('Last updated') + expect(first_issue).to include(last_updated_issuable.title) + expect(last_issue).to include(first_updated_issuable.title) + end + end + + context 'in the "issues / all" tab', :js do + let(:issuable_type) { :issue } + + it 'is "created date"' do + visit_issues_with_state(project, 'all') + + expect(find('.issues-other-filters')).to have_content('Created date') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + + context 'when the sort in the URL is id_desc' do + let(:issuable_type) { :issue } + + before do + visit_issues(project, sort: 'id_desc') + end + + it 'shows the sort order as created date' do + expect(find('.issues-other-filters')).to have_content('Created date') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + end + + context 'custom sorting' do + let(:issuable_type) { :issue } + + it 'supports sorting in asc and desc order' do + visit_issues_with_state(project, 'open') + + page.within('.issues-other-filters') do + click_button('Created date') + click_link('Last updated') + end + + expect(first_issue).to include(last_updated_issuable.title) + expect(last_issue).to include(first_updated_issuable.title) + + find('.issues-other-filters .filter-dropdown-container .qa-reverse-sort').click + + expect(first_issue).to include(first_updated_issuable.title) + expect(last_issue).to include(last_updated_issuable.title) + end + end + end + + def selected_sort_order + find('.filter-dropdown-container .dropdown button').text.downcase + end + + def visit_merge_requests_with_state(project, state) + visit_merge_requests project, state: state + end + + def visit_issues_with_state(project, state) + visit_issues project, state: state + end +end diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb index ca5d506ab04..b25b1514d62 100644 --- a/spec/features/issues/filtered_search/dropdown_label_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb @@ -45,7 +45,8 @@ describe 'Dropdown label', :js do bug_label = create(:label, project: project, title: 'bug-label') init_label_search - filtered_search.native.send_keys(:down, :down, :enter) + # navigate to the bug_label option and selects it + filtered_search.native.send_keys(:down, :down, :down, :enter) expect_tokens([label_token(bug_label.title)]) expect_filtered_search_input_empty @@ -234,12 +235,20 @@ describe 'Dropdown label', :js do end it 'selects `no label`' do - find("#{js_dropdown_label} .filter-dropdown-item", text: 'No Label').click + find("#{js_dropdown_label} .filter-dropdown-item", text: 'None').click expect(page).not_to have_css(js_dropdown_label) expect_tokens([label_token('none', false)]) expect_filtered_search_input_empty end + + it 'selects `any label`' do + find("#{js_dropdown_label} .filter-dropdown-item", text: 'Any').click + + expect(page).not_to have_css(js_dropdown_label) + expect_tokens([label_token('any', false)]) + expect_filtered_search_input_empty + end end describe 'input has existing content' do diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 35d57b3896d..a29380a180e 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -430,10 +430,10 @@ describe 'Filter issues', :js do expect_issues_list_count(2) - sort_toggle = find('.filtered-search-wrapper .dropdown-toggle') + sort_toggle = find('.filter-dropdown-container .dropdown') sort_toggle.click - find('.filtered-search-wrapper .dropdown-menu li a', text: 'Created date').click + find('.filter-dropdown-container .dropdown-menu li a', text: 'Created date').click wait_for_requests expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(new_issue.title) diff --git a/spec/features/issues/user_comments_on_issue_spec.rb b/spec/features/issues/user_comments_on_issue_spec.rb index ba5b80ed04b..b4b9a589ba3 100644 --- a/spec/features/issues/user_comments_on_issue_spec.rb +++ b/spec/features/issues/user_comments_on_issue_spec.rb @@ -40,6 +40,18 @@ describe "User comments on issue", :js do expect(page.find('pre code').text).to eq code_block_content end + + it "does not render html content in mermaid" do + html_content = "<img onerror=location=`javascript\\u003aalert\\u0028document.domain\\u0029` src=x>" + mermaid_content = "graph LR\n B-->D(#{html_content});" + comment = "```mermaid\n#{mermaid_content}\n```" + + add_note(comment) + + wait_for_requests + + expect(page.find('svg.mermaid')).to have_content html_content + end end context "when editing comments" do diff --git a/spec/features/issues/user_sees_breadcrumb_links_spec.rb b/spec/features/issues/user_sees_breadcrumb_links_spec.rb index ca234321235..43369f7609f 100644 --- a/spec/features/issues/user_sees_breadcrumb_links_spec.rb +++ b/spec/features/issues/user_sees_breadcrumb_links_spec.rb @@ -1,15 +1,15 @@ require 'rails_helper' -describe 'New issue breadcrumbs' do +describe 'New issue breadcrumb' do let(:project) { create(:project) } - let(:user) { project.creator } + let(:user) { project.creator } before do sign_in(user) - visit new_project_issue_path(project) + visit(new_project_issue_path(project)) end - it 'display a link to project issues and new issue pages' do + it 'displays link to project issues and new issue' do page.within '.breadcrumbs' do expect(find_link('Issues')[:href]).to end_with(project_issues_path(project)) expect(find_link('New')[:href]).to end_with(new_project_issue_path(project)) diff --git a/spec/features/issues/user_sorts_issues_spec.rb b/spec/features/issues/user_sorts_issues_spec.rb index 4771d2c6d28..eebd2d57cca 100644 --- a/spec/features/issues/user_sorts_issues_spec.rb +++ b/spec/features/issues/user_sorts_issues_spec.rb @@ -20,9 +20,9 @@ describe "User sorts issues" do end it 'keeps the sort option' do - find('button.dropdown-toggle').click + find('.filter-dropdown-container .dropdown').click - page.within('.content ul.dropdown-menu.dropdown-menu-right li') do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link('Milestone') end @@ -40,9 +40,9 @@ describe "User sorts issues" do end it "sorts by popularity" do - find("button.dropdown-toggle").click + find('.filter-dropdown-container .dropdown').click - page.within(".content ul.dropdown-menu.dropdown-menu-right li") do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link("Popularity") end diff --git a/spec/features/markdown/mermaid_spec.rb b/spec/features/markdown/mermaid_spec.rb index a25d701ee35..7008b361394 100644 --- a/spec/features/markdown/mermaid_spec.rb +++ b/spec/features/markdown/mermaid_spec.rb @@ -18,7 +18,7 @@ describe 'Mermaid rendering', :js do visit project_issue_path(project, issue) %w[A B C D].each do |label| - expect(page).to have_selector('svg foreignObject', text: label) + expect(page).to have_selector('svg text', text: label) end end end diff --git a/spec/features/merge_request/user_expands_diff_spec.rb b/spec/features/merge_request/user_expands_diff_spec.rb new file mode 100644 index 00000000000..3560b8d90bb --- /dev/null +++ b/spec/features/merge_request/user_expands_diff_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe 'User expands diff', :js do + let(:project) { create(:project, :public, :repository) } + let(:merge_request) { create(:merge_request, source_branch: 'expand-collapse-files', source_project: project, target_project: project) } + + before do + allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes) + allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(10.kilobytes) + + visit(diffs_project_merge_request_path(project, merge_request)) + + wait_for_requests + end + + it 'allows user to expand diff' do + page.within find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd"]') do + click_link 'Click to expand it.' + + wait_for_requests + + expect(page).not_to have_content('Click to expand it.') + expect(page).to have_selector('.code') + end + end +end diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb index 87613a4fdce..328f96e6ed7 100644 --- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb +++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb @@ -50,7 +50,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do find('.line-resolve-btn').click expect(page).to have_selector('.line-resolve-btn.is-active') - expect(find('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") + expect(find('.line-resolve-btn')['aria-label']).to eq("Resolved by #{user.name}") end page.within '.diff-content' do @@ -243,7 +243,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do resolve_button.click wait_for_requests - expect(resolve_button['data-original-title']).to eq("Resolved by #{user.name}") + expect(resolve_button['aria-label']).to eq("Resolved by #{user.name}") end end @@ -266,7 +266,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do wait_for_requests - expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") + expect(first('.line-resolve-btn')['aria-label']).to eq("Resolved by #{user.name}") end expect(page).to have_content('Last updated') @@ -285,7 +285,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do wait_for_requests resolve_buttons.each do |button| - expect(button['data-original-title']).to eq("Resolved by #{user.name}") + expect(button['aria-label']).to eq("Resolved by #{user.name}") end page.within '.line-resolve-all-container' do @@ -357,13 +357,12 @@ describe 'Merge request > User resolves diff notes and discussions', :js do resolve_button.click wait_for_requests - expect(resolve_button['data-original-title']).to eq("Resolved by #{user.name}") + expect(resolve_button['aria-label']).to eq("Resolved by #{user.name}") end end - it 'shows jump to next discussion button, apart from the last one' do - expect(page).to have_selector('.discussion-reply-holder', count: 2) - expect(page).to have_selector('.discussion-reply-holder .discussion-next-btn', count: 1) + it 'shows jump to next discussion button' do + expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn')) end it 'displays next discussion even if hidden' do diff --git a/spec/features/merge_request/user_sees_breadcrumb_links_spec.rb b/spec/features/merge_request/user_sees_breadcrumb_links_spec.rb index f17acb35a5a..18d204da17a 100644 --- a/spec/features/merge_request/user_sees_breadcrumb_links_spec.rb +++ b/spec/features/merge_request/user_sees_breadcrumb_links_spec.rb @@ -1,15 +1,15 @@ require 'rails_helper' -describe 'New merge request breadcrumbs' do +describe 'New merge request breadcrumb' do let(:project) { create(:project, :repository) } - let(:user) { project.creator } + let(:user) { project.creator } before do sign_in(user) - visit project_new_merge_request_path(project) + visit(project_new_merge_request_path(project)) end - it 'display a link to project merge requests and new merge request pages' do + it 'displays link to project merge requests and new merge request' do page.within '.breadcrumbs' do expect(find_link('Merge Requests')[:href]).to end_with(project_merge_requests_path(project)) expect(find_link('New')[:href]).to end_with(project_new_merge_request_path(project)) diff --git a/spec/features/merge_request/user_sees_deployment_widget_spec.rb b/spec/features/merge_request/user_sees_deployment_widget_spec.rb index 74290c0fff9..fe8e0b07d39 100644 --- a/spec/features/merge_request/user_sees_deployment_widget_spec.rb +++ b/spec/features/merge_request/user_sees_deployment_widget_spec.rb @@ -29,6 +29,22 @@ describe 'Merge request > User sees deployment widget', :js do expect(page).to have_content("Deployed to #{environment.name}") expect(find('.js-deploy-time')['data-original-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) end + + context 'when a user created a new merge request with the same SHA' do + let(:pipeline2) { create(:ci_pipeline_without_jobs, sha: sha, project: project, ref: 'new-patch-1') } + let(:build2) { create(:ci_build, :success, pipeline: pipeline2) } + let(:environment2) { create(:environment, project: project) } + let!(:deployment2) { create(:deployment, environment: environment2, sha: sha, ref: 'new-patch-1', deployable: build2) } + + it 'displays one environment which is related to the pipeline' do + visit project_merge_request_path(project, merge_request) + wait_for_requests + + expect(page).to have_selector('.js-deployment-info', count: 1) + expect(page).to have_content("#{environment.name}") + expect(page).not_to have_content("#{environment2.name}") + end + end end context 'when deployment failed' do @@ -65,7 +81,20 @@ describe 'Merge request > User sees deployment widget', :js do visit project_merge_request_path(project, merge_request) wait_for_requests - expect(page).to have_content("Deploying to #{environment.name}") + expect(page).to have_content("Will deploy to #{environment.name}") + expect(page).not_to have_css('.js-deploy-time') + end + end + + context 'when deployment was cancelled' do + let(:build) { create(:ci_build, :canceled, pipeline: pipeline) } + let!(:deployment) { create(:deployment, :canceled, environment: environment, sha: sha, ref: ref, deployable: build) } + + it 'displays that the environment name' do + visit project_merge_request_path(project, merge_request) + wait_for_requests + + expect(page).to have_content("Failed to deploy to #{environment.name}") expect(page).not_to have_css('.js-deploy-time') end end diff --git a/spec/features/merge_request/user_sees_discussions_spec.rb b/spec/features/merge_request/user_sees_discussions_spec.rb index 7b8c3bacfe2..4ab9a87ad4b 100644 --- a/spec/features/merge_request/user_sees_discussions_spec.rb +++ b/spec/features/merge_request/user_sees_discussions_spec.rb @@ -53,13 +53,11 @@ describe 'Merge request > User sees discussions', :js do shared_examples 'a functional discussion' do let(:discussion_id) { note.discussion_id(merge_request) } - # TODO: https://gitlab.com/gitlab-org/gitlab-ce/issues/48034 - xit 'is displayed' do + it 'is displayed' do expect(page).to have_css(".discussion[data-discussion-id='#{discussion_id}']") end - # TODO: https://gitlab.com/gitlab-org/gitlab-ce/issues/48034 - xit 'can be replied to' do + it 'can be replied to' do within(".discussion[data-discussion-id='#{discussion_id}']") do click_button 'Reply...' fill_in 'note[note]', with: 'Test!' @@ -74,16 +72,21 @@ describe 'Merge request > User sees discussions', :js do visit project_merge_request_path(project, merge_request) end - context 'a regular commit comment' do - let(:note) { create(:note_on_commit, project: project) } - - it_behaves_like 'a functional discussion' - end + # TODO: https://gitlab.com/gitlab-org/gitlab-ce/issues/48034 + # context 'a regular commit comment' do + # let(:note) { create(:note_on_commit, project: project) } + # + # it_behaves_like 'a functional discussion' + # end context 'a commit diff comment' do let(:note) { create(:diff_note_on_commit, project: project) } it_behaves_like 'a functional discussion' + + it 'displays correct header' do + expect(page).to have_content "started a discussion on commit #{note.commit_id[0...7]}" + end end end end diff --git a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb new file mode 100644 index 00000000000..7b473faa884 --- /dev/null +++ b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb @@ -0,0 +1,365 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Merge request > User sees merge request pipelines', :js do + include ProjectForksHelper + include TestReportsHelper + + let(:project) { create(:project, :public, :repository) } + let(:user) { project.creator } + + let(:config) do + { + build: { + script: 'build' + }, + test: { + script: 'test', + only: ['merge_requests'] + }, + deploy: { + script: 'deploy', + except: ['merge_requests'] + } + } + end + + before do + stub_application_setting(auto_devops_enabled: false) + stub_feature_flags(ci_merge_request_pipeline: true) + stub_ci_pipeline_yaml_file(YAML.dump(config)) + project.add_maintainer(user) + sign_in(user) + end + + context 'when a user created a merge request in the parent project' do + let(:merge_request) do + create(:merge_request, + source_project: project, + target_project: project, + source_branch: 'feature', + target_branch: 'master') + end + + let!(:push_pipeline) do + Ci::CreatePipelineService.new(project, user, ref: 'feature') + .execute(:push) + end + + let!(:merge_request_pipeline) do + Ci::CreatePipelineService.new(project, user, ref: 'feature') + .execute(:merge_request, merge_request: merge_request) + end + + before do + visit project_merge_request_path(project, merge_request) + + page.within('.merge-request-tabs') do + click_link('Pipelines') + end + end + + it 'sees branch pipelines and merge request pipelines in correct order' do + page.within('.ci-table') do + expect(page).to have_selector('.ci-pending', count: 2) + expect(first('.js-pipeline-url-link')).to have_content("##{merge_request_pipeline.id}") + end + end + + it 'sees the latest merge request pipeline as the head pipeline' do + page.within('.ci-widget-content') do + expect(page).to have_content("##{merge_request_pipeline.id}") + end + end + + context 'when a user updated a merge request in the parent project' do + let!(:push_pipeline_2) do + Ci::CreatePipelineService.new(project, user, ref: 'feature') + .execute(:push) + end + + let!(:merge_request_pipeline_2) do + Ci::CreatePipelineService.new(project, user, ref: 'feature') + .execute(:merge_request, merge_request: merge_request) + end + + before do + visit project_merge_request_path(project, merge_request) + + page.within('.merge-request-tabs') do + click_link('Pipelines') + end + end + + it 'sees branch pipelines and merge request pipelines in correct order' do + page.within('.ci-table') do + expect(page).to have_selector('.ci-pending', count: 4) + + expect(all('.js-pipeline-url-link')[0]) + .to have_content("##{merge_request_pipeline_2.id}") + + expect(all('.js-pipeline-url-link')[1]) + .to have_content("##{merge_request_pipeline.id}") + + expect(all('.js-pipeline-url-link')[2]) + .to have_content("##{push_pipeline_2.id}") + + expect(all('.js-pipeline-url-link')[3]) + .to have_content("##{push_pipeline.id}") + end + end + + it 'sees merge request tag for merge request pipelines' do + page.within('.ci-table') do + expect(all('.pipeline-tags')[0]) + .to have_content("merge request") + + expect(all('.pipeline-tags')[1]) + .to have_content("merge request") + + expect(all('.pipeline-tags')[2]) + .not_to have_content("merge request") + + expect(all('.pipeline-tags')[3]) + .not_to have_content("merge request") + end + end + + it 'sees the latest merge request pipeline as the head pipeline' do + page.within('.ci-widget-content') do + expect(page).to have_content("##{merge_request_pipeline_2.id}") + end + end + end + + context 'when a user merges a merge request in the parent project' do + before do + click_button 'Merge when pipeline succeeds' + + wait_for_requests + end + + context 'when merge request pipeline is pending' do + it 'waits the head pipeline' do + expect(page).to have_content('to be merged automatically when the pipeline succeeds') + expect(page).to have_link('Cancel automatic merge') + end + end + + context 'when merge request pipeline succeeds' do + before do + merge_request_pipeline.succeed! + + wait_for_requests + end + + it 'merges the merge request' do + expect(page).to have_content('Merged by') + expect(page).to have_link('Revert') + end + end + + context 'when branch pipeline succeeds' do + before do + push_pipeline.succeed! + + wait_for_requests + end + + it 'waits the head pipeline' do + expect(page).to have_content('to be merged automatically when the pipeline succeeds') + expect(page).to have_link('Cancel automatic merge') + end + end + end + + context 'when there are no `merge_requests` keyword in .gitlab-ci.yml' do + let(:config) do + { + build: { + script: 'build' + }, + test: { + script: 'test' + }, + deploy: { + script: 'deploy' + } + } + end + + it 'sees a branch pipeline in pipeline tab' do + page.within('.ci-table') do + expect(page).to have_selector('.ci-pending', count: 1) + expect(first('.js-pipeline-url-link')).to have_content("##{push_pipeline.id}") + end + end + + it 'sees the latest branch pipeline as the head pipeline' do + page.within('.ci-widget-content') do + expect(page).to have_content("##{push_pipeline.id}") + end + end + end + end + + context 'when a user created a merge request from a forked project to the parent project' do + let(:merge_request) do + create(:merge_request, + source_project: forked_project, + target_project: project, + source_branch: 'feature', + target_branch: 'master') + end + + let!(:push_pipeline) do + Ci::CreatePipelineService.new(forked_project, user2, ref: 'feature') + .execute(:push) + end + + let!(:merge_request_pipeline) do + Ci::CreatePipelineService.new(forked_project, user2, ref: 'feature') + .execute(:merge_request, merge_request: merge_request) + end + + let(:forked_project) { fork_project(project, user2, repository: true) } + let(:user2) { create(:user) } + + before do + forked_project.add_maintainer(user2) + + visit project_merge_request_path(project, merge_request) + + page.within('.merge-request-tabs') do + click_link('Pipelines') + end + end + + it 'sees branch pipelines and merge request pipelines in correct order' do + page.within('.ci-table') do + expect(page).to have_selector('.ci-pending', count: 2) + expect(first('.js-pipeline-url-link')).to have_content("##{merge_request_pipeline.id}") + end + end + + it 'sees the latest merge request pipeline as the head pipeline' do + page.within('.ci-widget-content') do + expect(page).to have_content("##{merge_request_pipeline.id}") + end + end + + it 'sees pipeline list in forked project' do + visit project_pipelines_path(forked_project) + + expect(page).to have_selector('.ci-pending', count: 2) + end + + context 'when a user updated a merge request from a forked project to the parent project' do + let!(:push_pipeline_2) do + Ci::CreatePipelineService.new(forked_project, user2, ref: 'feature') + .execute(:push) + end + + let!(:merge_request_pipeline_2) do + Ci::CreatePipelineService.new(forked_project, user2, ref: 'feature') + .execute(:merge_request, merge_request: merge_request) + end + + before do + visit project_merge_request_path(project, merge_request) + + page.within('.merge-request-tabs') do + click_link('Pipelines') + end + end + + it 'sees branch pipelines and merge request pipelines in correct order' do + page.within('.ci-table') do + expect(page).to have_selector('.ci-pending', count: 4) + + expect(all('.js-pipeline-url-link')[0]) + .to have_content("##{merge_request_pipeline_2.id}") + + expect(all('.js-pipeline-url-link')[1]) + .to have_content("##{merge_request_pipeline.id}") + + expect(all('.js-pipeline-url-link')[2]) + .to have_content("##{push_pipeline_2.id}") + + expect(all('.js-pipeline-url-link')[3]) + .to have_content("##{push_pipeline.id}") + end + end + + it 'sees merge request tag for merge request pipelines' do + page.within('.ci-table') do + expect(all('.pipeline-tags')[0]) + .to have_content("merge request") + + expect(all('.pipeline-tags')[1]) + .to have_content("merge request") + + expect(all('.pipeline-tags')[2]) + .not_to have_content("merge request") + + expect(all('.pipeline-tags')[3]) + .not_to have_content("merge request") + end + end + + it 'sees the latest merge request pipeline as the head pipeline' do + page.within('.ci-widget-content') do + expect(page).to have_content("##{merge_request_pipeline_2.id}") + end + end + + it 'sees pipeline list in forked project' do + visit project_pipelines_path(forked_project) + + expect(page).to have_selector('.ci-pending', count: 4) + end + end + + context 'when a user merges a merge request from a forked project to the parent project' do + before do + click_button 'Merge when pipeline succeeds' + + wait_for_requests + end + + context 'when merge request pipeline is pending' do + it 'waits the head pipeline' do + expect(page).to have_content('to be merged automatically when the pipeline succeeds') + expect(page).to have_link('Cancel automatic merge') + end + end + + context 'when merge request pipeline succeeds' do + before do + merge_request_pipeline.succeed! + + wait_for_requests + end + + it 'merges the merge request' do + expect(page).to have_content('Merged by') + expect(page).to have_link('Revert') + end + end + + context 'when branch pipeline succeeds' do + before do + push_pipeline.succeed! + + wait_for_requests + end + + it 'waits the head pipeline' do + expect(page).to have_content('to be merged automatically when the pipeline succeeds') + expect(page).to have_link('Cancel automatic merge') + end + end + end + end +end diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb index 582be101399..d8ebd3c92af 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -60,7 +60,7 @@ describe 'Merge request > User sees merge widget', :js do it 'shows environments link' do wait_for_requests - page.within('.js-pre-merge-deploy') do + page.within('.js-pre-deployment') do expect(page).to have_content("Deployed to #{environment.name}") expect(find('.js-deploy-url')[:href]).to include(environment.formatted_external_url) end diff --git a/spec/features/merge_requests/user_sorts_merge_requests_spec.rb b/spec/features/merge_requests/user_sorts_merge_requests_spec.rb index e163868e8e7..fa887110c13 100644 --- a/spec/features/merge_requests/user_sorts_merge_requests_spec.rb +++ b/spec/features/merge_requests/user_sorts_merge_requests_spec.rb @@ -19,9 +19,9 @@ describe 'User sorts merge requests' do end it 'keeps the sort option' do - find('button.dropdown-toggle').click + find('.filter-dropdown-container .dropdown').click - page.within('.content ul.dropdown-menu.dropdown-menu-right li') do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link('Milestone') end @@ -49,9 +49,9 @@ describe 'User sorts merge requests' do it 'separates remember sorting with issues' do create(:issue, project: project) - find('button.dropdown-toggle').click + find('.filter-dropdown-container .dropdown').click - page.within('.content ul.dropdown-menu.dropdown-menu-right li') do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link('Milestone') end @@ -70,9 +70,9 @@ describe 'User sorts merge requests' do end it 'sorts by popularity' do - find('button.dropdown-toggle').click + find('.filter-dropdown-container .dropdown').click - page.within('.content ul.dropdown-menu.dropdown-menu-right li') do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link('Popularity') end diff --git a/spec/features/milestones/user_promotes_milestone_spec.rb b/spec/features/milestones/user_promotes_milestone_spec.rb new file mode 100644 index 00000000000..df1bc502134 --- /dev/null +++ b/spec/features/milestones/user_promotes_milestone_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +describe 'User promotes milestone' do + set(:group) { create(:group) } + set(:user) { create(:user) } + set(:project) { create(:project, namespace: group) } + set(:milestone) { create(:milestone, project: project) } + + context 'when user can admin group milestones' do + before do + group.add_developer(user) + sign_in(user) + visit(project_milestones_path(project)) + end + + it "shows milestone promote button" do + expect(page).to have_selector('.js-promote-project-milestone-button') + end + end + + context 'when user cannot admin group milestones' do + before do + project.add_developer(user) + sign_in(user) + visit(project_milestones_path(project)) + end + + it "does not show milestone promote button" do + expect(page).not_to have_selector('.js-promote-project-milestone-button') + end + end +end diff --git a/spec/features/milestones/user_sees_breadcrumb_links_spec.rb b/spec/features/milestones/user_sees_breadcrumb_links_spec.rb new file mode 100644 index 00000000000..d3906ea73bd --- /dev/null +++ b/spec/features/milestones/user_sees_breadcrumb_links_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +describe 'New project milestone breadcrumb' do + let(:project) { create(:project) } + let(:milestone) { create(:milestone, project: project) } + let(:user) { project.creator } + + before do + sign_in(user) + visit(new_project_milestone_path(project)) + end + + it 'displays link to project milestones and new project milestone' do + page.within '.breadcrumbs' do + expect(find_link('Milestones')[:href]).to end_with(project_milestones_path(project)) + expect(find_link('New')[:href]).to end_with(new_project_milestone_path(project)) + end + end +end diff --git a/spec/features/projects/commit/builds_spec.rb b/spec/features/projects/commit/builds_spec.rb index bd254caddfb..caf69796d52 100644 --- a/spec/features/projects/commit/builds_spec.rb +++ b/spec/features/projects/commit/builds_spec.rb @@ -20,7 +20,7 @@ describe 'project commit pipelines', :js do visit pipelines_project_commit_path(project, project.commit.sha) page.within('.table-holder') do - expect(page).to have_content project.pipelines[0].id # pipeline ids + expect(page).to have_content project.ci_pipelines[0].id # pipeline ids end end end diff --git a/spec/features/projects/commits/user_browses_commits_spec.rb b/spec/features/projects/commits/user_browses_commits_spec.rb index 534cfe1eb12..2159adf49fc 100644 --- a/spec/features/projects/commits/user_browses_commits_spec.rb +++ b/spec/features/projects/commits/user_browses_commits_spec.rb @@ -4,10 +4,9 @@ describe 'User browses commits' do include RepoHelpers let(:user) { create(:user) } - let(:project) { create(:project, :repository, namespace: user.namespace) } + let(:project) { create(:project, :public, :repository, namespace: user.namespace) } before do - project.add_maintainer(user) sign_in(user) end @@ -127,6 +126,26 @@ describe 'User browses commits' do .and have_selector('entry summary', text: commit.description[0..10].delete("\r\n")) end + context 'when a commit links to a confidential issue' do + let(:confidential_issue) { create(:issue, confidential: true, title: 'Secret issue!', project: project) } + + before do + project.repository.create_file(user, 'dummy-file', 'dummy content', + branch_name: 'feature', + message: "Linking #{confidential_issue.to_reference}") + end + + context 'when the user cannot see confidential issues but was cached with a link', :use_clean_rails_memory_store_fragment_caching do + it 'does not render the confidential issue' do + visit project_commits_path(project, 'feature') + sign_in(create(:user)) + visit project_commits_path(project, 'feature') + + expect(page).not_to have_link(href: project_issue_path(project, confidential_issue)) + end + end + end + context 'master branch' do before do visit_commits_page diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 9772a7bacac..a8a3b6910fb 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -165,8 +165,14 @@ describe 'Environment' do context 'web terminal', :js do before do - # Stub #terminals as it causes js-enabled feature specs to render the page incorrectly - allow_any_instance_of(Environment).to receive(:terminals) { nil } + # Stub #terminals as it causes js-enabled feature specs to + # render the page incorrectly + # + # In EE we have to stub EE::Environment since it overwrites + # the "terminals" method. + allow_any_instance_of(defined?(EE) ? EE::Environment : Environment) + .to receive(:terminals) { nil } + visit terminal_project_environment_path(project, environment) end diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index 4f93e512f1d..651c02c7ecc 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -795,7 +795,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do it 'renders message about job being stuck because no runners are active' do expect(page).to have_css('.js-stuck-no-active-runner') - expect(page).to have_content("This job is stuck, because you don't have any active runners that can run this job.") + expect(page).to have_content("This job is stuck because you don't have any active runners that can run this job.") end end @@ -805,7 +805,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do it 'renders message about job being stuck because of no runners with the specified tags' do expect(page).to have_css('.js-stuck-with-tags') - expect(page).to have_content("This job is stuck, because you don't have any active runners online with any of these tags assigned to them:") + expect(page).to have_content("This job is stuck because you don't have any active runners online with any of these tags assigned to them:") end end @@ -815,7 +815,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do it 'renders message about job being stuck because of no runners with the specified tags' do expect(page).to have_css('.js-stuck-with-tags') - expect(page).to have_content("This job is stuck, because you don't have any active runners online with any of these tags assigned to them:") + expect(page).to have_content("This job is stuck because you don't have any active runners online with any of these tags assigned to them:") end end @@ -824,7 +824,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do it 'renders message about job being stuck because not runners are available' do expect(page).to have_css('.js-stuck-no-active-runner') - expect(page).to have_content("This job is stuck, because you don't have any active runners that can run this job.") + expect(page).to have_content("This job is stuck because you don't have any active runners that can run this job.") end end @@ -834,7 +834,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do it 'renders message about job being stuck because runners are offline' do expect(page).to have_css('.js-stuck-no-runners') - expect(page).to have_content("This job is stuck, because the project doesn't have any runners online assigned to it.") + expect(page).to have_content("This job is stuck because the project doesn't have any runners online assigned to it.") end end end diff --git a/spec/features/projects/labels/issues_sorted_by_priority_spec.rb b/spec/features/projects/labels/issues_sorted_by_priority_spec.rb index 6178f11ded7..25417cf4955 100644 --- a/spec/features/projects/labels/issues_sorted_by_priority_spec.rb +++ b/spec/features/projects/labels/issues_sorted_by_priority_spec.rb @@ -32,7 +32,7 @@ describe 'Issue prioritization' do visit project_issues_path(project, sort: 'label_priority') # Ensure we are indicating that issues are sorted by priority - expect(page).to have_selector('.dropdown-toggle', text: 'Label priority') + expect(page).to have_selector('.dropdown', text: 'Label priority') page.within('.issues-holder') do issue_titles = all('.issues-list .issue-title-text').map(&:text) @@ -70,7 +70,7 @@ describe 'Issue prioritization' do sign_in user visit project_issues_path(project, sort: 'label_priority') - expect(page).to have_selector('.dropdown-toggle', text: 'Label priority') + expect(page).to have_selector('.dropdown', text: 'Label priority') page.within('.issues-holder') do issue_titles = all('.issues-list .issue-title-text').map(&:text) diff --git a/spec/features/projects/labels/update_prioritization_spec.rb b/spec/features/projects/labels/update_prioritization_spec.rb index 996040fde02..055a0c83a11 100644 --- a/spec/features/projects/labels/update_prioritization_spec.rb +++ b/spec/features/projects/labels/update_prioritization_spec.rb @@ -115,6 +115,21 @@ describe 'Prioritize labels' do end end + it 'user can see a primary button when there are only prioritized labels', :js do + visit project_labels_path(project) + + page.within('.other-labels') do + all('.js-toggle-priority').each do |el| + el.click + end + wait_for_requests + end + + page.within('.breadcrumbs-container') do + expect(page).to have_link('New label') + end + end + it 'shows a help message about prioritized labels' do visit project_labels_path(project) diff --git a/spec/features/projects/labels/user_sees_breadcrumb_links_spec.rb b/spec/features/projects/labels/user_sees_breadcrumb_links_spec.rb new file mode 100644 index 00000000000..0c0501f438a --- /dev/null +++ b/spec/features/projects/labels/user_sees_breadcrumb_links_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +describe 'New project label breadcrumb' do + let(:project) { create(:project) } + let(:user) { project.creator } + + before do + sign_in(user) + visit(project_labels_path(project)) + end + + it 'displays link to project labels and new project label' do + page.within '.breadcrumbs' do + expect(find_link('Labels')[:href]).to end_with(project_labels_path(project)) + end + end +end diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/list_spec.rb index c2e980e75b8..cf309492808 100644 --- a/spec/features/projects/members/list_spec.rb +++ b/spec/features/projects/members/list_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe 'Project members list' do include Select2Helper + include Spec::Support::Helpers::Features::ListRowsHelpers let(:user1) { create(:user, name: 'John Doe') } let(:user2) { create(:user, name: 'Mary Jane') } @@ -83,14 +84,6 @@ describe 'Project members list' do end end - def first_row - page.all('ul.content-list > li')[0] - end - - def second_row - page.all('ul.content-list > li')[1] - end - def add_user(id, role) page.within ".users-project-form" do select2(id, from: "#user_ids", multiple: true) diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb index 831f22a0e69..435fb229b69 100644 --- a/spec/features/projects/pages_spec.rb +++ b/spec/features/projects/pages_spec.rb @@ -300,7 +300,7 @@ describe 'Pages' do let(:pipeline) do commit_sha = project.commit('HEAD').sha - project.pipelines.create( + project.ci_pipelines.create( ref: 'HEAD', sha: commit_sha, source: :push, diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 049bbca958f..a37ad9c3f43 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -499,4 +499,154 @@ describe 'Pipeline', :js do end end end + + context 'when user sees pipeline flags in a pipeline detail page' do + let(:project) { create(:project, :repository) } + + context 'when pipeline is latest' do + include_context 'pipeline builds' + + let(:pipeline) do + create(:ci_pipeline, + project: project, + ref: 'master', + sha: project.commit.id, + user: user) + end + + before do + visit project_pipeline_path(project, pipeline) + end + + it 'contains badge that indicates it is the latest build' do + page.within(all('.well-segment')[1]) do + expect(page).to have_content 'latest' + end + end + end + + context 'when pipeline has configuration errors' do + include_context 'pipeline builds' + + let(:pipeline) do + create(:ci_pipeline, + :invalid, + project: project, + ref: 'master', + sha: project.commit.id, + user: user) + end + + before do + visit project_pipeline_path(project, pipeline) + end + + it 'contains badge that indicates errors' do + page.within(all('.well-segment')[1]) do + expect(page).to have_content 'yaml invalid' + end + end + + it 'contains badge with tooltip which contains error' do + expect(pipeline).to have_yaml_errors + + page.within(all('.well-segment')[1]) do + expect(page).to have_selector( + %Q{span[title="#{pipeline.yaml_errors}"]}) + end + end + + it 'contains badge that indicates failure reason' do + expect(page).to have_content 'error' + end + + it 'contains badge with tooltip which contains failure reason' do + expect(pipeline.failure_reason?).to eq true + + page.within(all('.well-segment')[1]) do + expect(page).to have_selector( + %Q{span[title="#{pipeline.present.failure_reason}"]}) + end + end + end + + context 'when pipeline is stuck' do + include_context 'pipeline builds' + + let(:pipeline) do + create(:ci_pipeline, + project: project, + ref: 'master', + sha: project.commit.id, + user: user) + end + + before do + create(:ci_build, :pending, pipeline: pipeline) + visit project_pipeline_path(project, pipeline) + end + + it 'contains badge that indicates being stuck' do + page.within(all('.well-segment')[1]) do + expect(page).to have_content 'stuck' + end + end + end + + context 'when pipeline uses auto devops' do + include_context 'pipeline builds' + + let(:project) { create(:project, :repository, auto_devops_attributes: { enabled: true }) } + let(:pipeline) do + create(:ci_pipeline, + :auto_devops_source, + project: project, + ref: 'master', + sha: project.commit.id, + user: user) + end + + before do + visit project_pipeline_path(project, pipeline) + end + + it 'contains badge that indicates using auto devops' do + page.within(all('.well-segment')[1]) do + expect(page).to have_content 'Auto DevOps' + end + end + end + + context 'when pipeline runs in a merge request context' do + include_context 'pipeline builds' + + let(:pipeline) do + create(:ci_pipeline, + source: :merge_request, + project: merge_request.source_project, + ref: 'feature', + sha: merge_request.diff_head_sha, + user: user, + merge_request: merge_request) + end + + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: 'feature', + target_project: project, + target_branch: 'master') + end + + before do + visit project_pipeline_path(project, pipeline) + end + + it 'contains badge that indicates merge request pipeline' do + page.within(all('.well-segment')[1]) do + expect(page).to have_content 'merge request' + end + end + end + end end diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb new file mode 100644 index 00000000000..766c63725b3 --- /dev/null +++ b/spec/features/projects/serverless/functions_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe 'Functions', :js do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + project.add_maintainer(user) + gitlab_sign_in(user) + end + + context 'when user does not have a cluster and visits the serverless page' do + before do + visit project_serverless_functions_path(project) + end + + it 'sees an empty state' do + expect(page).to have_link('Install Knative') + expect(page).to have_selector('.empty-state') + end + end + + context 'when the user does have a cluster and visits the serverless page' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + + before do + visit project_serverless_functions_path(project) + end + + it 'sees an empty state' do + expect(page).to have_link('Install Knative') + expect(page).to have_selector('.empty-state') + end + end + + context 'when the user has a cluster and knative installed and visits the serverless page' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } + let(:project) { knative.cluster.project } + + before do + visit project_serverless_functions_path(project) + end + + it 'sees an empty listing of serverless functions' do + expect(page).to have_selector('.gl-responsive-table-row') + end + end +end diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb index 401aac9478d..418e22f8c35 100644 --- a/spec/features/projects/settings/repository_settings_spec.rb +++ b/spec/features/projects/settings/repository_settings_spec.rb @@ -133,25 +133,102 @@ describe 'Projects > Settings > Repository settings' do expect(page).to have_selector('#mirror_direction') end + it 'creates a push mirror that mirrors all branches', :js do + expect(find('.js-mirror-protected-hidden', visible: false).value).to eq('0') + + fill_in 'url', with: 'ssh://user@localhost/project.git' + select 'SSH public key', from: 'Authentication method' + + select_direction + + Sidekiq::Testing.fake! do + click_button 'Mirror repository' + end + + project.reload + + expect(page).to have_content('Mirroring settings were successfully updated') + expect(project.remote_mirrors.first.only_protected_branches).to eq(false) + end + + it 'creates a push mirror that only mirrors protected branches', :js do + find('#only_protected_branches').click + + expect(find('.js-mirror-protected-hidden', visible: false).value).to eq('1') + + fill_in 'url', with: 'ssh://user@localhost/project.git' + select 'SSH public key', from: 'Authentication method' + + select_direction + + Sidekiq::Testing.fake! do + click_button 'Mirror repository' + end + + project.reload + + expect(page).to have_content('Mirroring settings were successfully updated') + expect(project.remote_mirrors.first.only_protected_branches).to eq(true) + end + it 'generates an SSH public key on submission', :js do fill_in 'url', with: 'ssh://user@localhost/project.git' select 'SSH public key', from: 'Authentication method' + select_direction + + Sidekiq::Testing.fake! do + click_button 'Mirror repository' + end + + expect(page).to have_content('Mirroring settings were successfully updated') + expect(page).to have_selector('[title="Copy SSH public key"]') + end + + def select_direction(direction = 'push') direction_select = find('#mirror_direction') # In CE, this select box is disabled, but in EE, it is enabled if direction_select.disabled? - expect(direction_select.value).to eq('push') + expect(direction_select.value).to eq(direction) else - direction_select.select('Push') + direction_select.select(direction.capitalize) end + end + end - Sidekiq::Testing.fake! do - click_button 'Mirror repository' + context 'repository cleanup settings' do + let(:object_map_file) { Rails.root.join('spec', 'fixtures', 'bfg_object_map.txt') } + + context 'feature enabled' do + it 'uploads an object map file', :js do + stub_feature_flags(project_cleanup: true) + + visit project_settings_repository_path(project) + + expect(page).to have_content('Repository cleanup') + + page.within('#cleanup') do + attach_file('project[bfg_object_map]', object_map_file, visible: false) + + Sidekiq::Testing.fake! do + click_button 'Start cleanup' + end + end + + expect(page).to have_content('Repository cleanup has started') + expect(RepositoryCleanupWorker.jobs.count).to eq(1) end + end - expect(page).to have_content('Mirroring settings were successfully updated') - expect(page).to have_selector('[title="Copy SSH public key"]') + context 'feature disabled' do + it 'does not show the settings' do + stub_feature_flags(project_cleanup: false) + + visit project_settings_repository_path(project) + + expect(page).not_to have_content('Repository cleanup') + end end end end diff --git a/spec/features/projects/show/developer_views_empty_project_instructions_spec.rb b/spec/features/projects/show/developer_views_empty_project_instructions_spec.rb index 227bdf524fe..8ba91fe7fd7 100644 --- a/spec/features/projects/show/developer_views_empty_project_instructions_spec.rb +++ b/spec/features/projects/show/developer_views_empty_project_instructions_spec.rb @@ -10,54 +10,9 @@ describe 'Projects > Show > Developer views empty project instructions' do sign_in(developer) end - context 'without an SSH key' do - it 'defaults to HTTP' do - visit_project - - expect_instructions_for('http') - end - - it 'switches to SSH', :js do - visit_project - - select_protocol('SSH') - - expect_instructions_for('ssh') - end - end - - context 'with an SSH key' do - before do - create(:personal_key, user: developer) - end - - it 'defaults to SSH' do - visit_project - - expect_instructions_for('ssh') - end - - it 'switches to HTTP', :js do - visit_project - - select_protocol('HTTP') - - expect_instructions_for('http') - end - end - - def visit_project + it 'displays "git clone" instructions' do visit project_path(project) - end - - def select_protocol(protocol) - find('#clone-dropdown').click - find(".#{protocol.downcase}-selector").click - end - - def expect_instructions_for(protocol) - msg = :"#{protocol.downcase}_url_to_repo" - expect(page).to have_content("git clone #{project.send(msg)}") + expect(page).to have_content("git clone") end end diff --git a/spec/features/projects/show/user_manages_notifications_spec.rb b/spec/features/projects/show/user_manages_notifications_spec.rb index 546619e88ec..88f3397608f 100644 --- a/spec/features/projects/show/user_manages_notifications_spec.rb +++ b/spec/features/projects/show/user_manages_notifications_spec.rb @@ -8,13 +8,18 @@ describe 'Projects > Show > User manages notifications', :js do visit project_path(project) end - it 'changes the notification setting' do + def click_notifications_button first('.notifications-btn').click + end + + it 'changes the notification setting' do + click_notifications_button click_link 'On mention' - page.within '#notifications-button' do - expect(page).to have_content 'On mention' - end + wait_for_requests + + click_notifications_button + expect(find('.update-notification.is-active')).to have_content('On mention') end context 'custom notification settings' do @@ -38,7 +43,7 @@ describe 'Projects > Show > User manages notifications', :js do end it 'shows notification settings checkbox' do - first('.notifications-btn').click + click_notifications_button page.find('a[data-notification-level="custom"]').click page.within('.custom-notifications-form') do diff --git a/spec/features/projects/show/user_sees_collaboration_links_spec.rb b/spec/features/projects/show/user_sees_collaboration_links_spec.rb index 7b3711531c6..24777788248 100644 --- a/spec/features/projects/show/user_sees_collaboration_links_spec.rb +++ b/spec/features/projects/show/user_sees_collaboration_links_spec.rb @@ -21,18 +21,6 @@ describe 'Projects > Show > Collaboration links' do end end - # The project header - page.within('.project-home-panel') do - aggregate_failures 'dropdown links in the project home panel' do - expect(page).to have_link('New issue') - expect(page).to have_link('New merge request') - expect(page).to have_link('New snippet') - expect(page).to have_link('New file') - expect(page).to have_link('New branch') - expect(page).to have_link('New tag') - end - end - # The dropdown above the tree page.within('.repo-breadcrumb') do aggregate_failures 'dropdown links above the repo tree' do @@ -61,17 +49,6 @@ describe 'Projects > Show > Collaboration links' do end end - page.within('.project-home-panel') do - aggregate_failures 'dropdown links' do - expect(page).not_to have_link('New issue') - expect(page).not_to have_link('New merge request') - expect(page).not_to have_link('New snippet') - expect(page).not_to have_link('New file') - expect(page).not_to have_link('New branch') - expect(page).not_to have_link('New tag') - end - end - page.within('.repo-breadcrumb') do aggregate_failures 'dropdown links' do expect(page).not_to have_link('New file') diff --git a/spec/features/projects/show/user_sees_git_instructions_spec.rb b/spec/features/projects/show/user_sees_git_instructions_spec.rb index 9a82fee1b5d..ffa80235083 100644 --- a/spec/features/projects/show/user_sees_git_instructions_spec.rb +++ b/spec/features/projects/show/user_sees_git_instructions_spec.rb @@ -29,7 +29,7 @@ describe 'Projects > Show > User sees Git instructions' do expect(element.text).to include(project.http_url_to_repo) end - expect(page).to have_field('project_clone', with: project.http_url_to_repo) unless user_has_ssh_key + expect(page).to have_field('http_project_clone', with: project.http_url_to_repo) unless user_has_ssh_key end end @@ -41,7 +41,7 @@ describe 'Projects > Show > User sees Git instructions' do expect(page).to have_content(project.title) end - expect(page).to have_field('project_clone', with: project.http_url_to_repo) unless user_has_ssh_key + expect(page).to have_field('http_project_clone', with: project.http_url_to_repo) unless user_has_ssh_key end end diff --git a/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb b/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb index df2b492ae6b..dcca1d388c7 100644 --- a/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb +++ b/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb @@ -21,7 +21,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do end it 'no Auto DevOps button if can not manage pipelines' do - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).not_to have_link('Enable Auto DevOps') expect(page).not_to have_link('Auto DevOps enabled') end @@ -30,7 +30,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do it '"Auto DevOps enabled" button not linked' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_text('Auto DevOps enabled') end end @@ -45,19 +45,19 @@ describe 'Projects > Show > User sees setup shortcut buttons' do end it '"New file" button linked to new file page' do - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_link('New file', href: project_new_blob_path(project, project.default_branch || 'master')) end end - it '"Add Readme" button linked to new file populated for a readme' do - page.within('.project-stats') do - expect(page).to have_link('Add Readme', href: presenter.add_readme_path) + it '"Add README" button linked to new file populated for a README' do + page.within('.project-buttons') do + expect(page).to have_link('Add README', href: presenter.add_readme_path) end end it '"Add license" button linked to new file populated for a license' do - page.within('.project-metadata') do + page.within('.project-stats') do expect(page).to have_link('Add license', href: presenter.add_license_path) end end @@ -67,7 +67,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do it '"Auto DevOps enabled" anchor linked to settings page' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_link('Auto DevOps enabled', href: project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) end end @@ -77,7 +77,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do let(:project) { create(:project, :public, :empty_repo, auto_devops_attributes: { enabled: false }) } it '"Enable Auto DevOps" button linked to settings page' do - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_link('Enable Auto DevOps', href: project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) end end @@ -86,7 +86,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do describe 'Kubernetes cluster button' do it '"Add Kubernetes cluster" button linked to clusters page' do - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_link('Add Kubernetes cluster', href: new_project_cluster_path(project)) end end @@ -96,7 +96,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_link('Kubernetes configured', href: project_cluster_path(project, cluster)) end end @@ -119,7 +119,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do it '"Auto DevOps enabled" button not linked' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_text('Auto DevOps enabled') end end @@ -129,14 +129,14 @@ describe 'Projects > Show > User sees setup shortcut buttons' do let(:project) { create(:project, :public, :repository, auto_devops_attributes: { enabled: false }) } it 'no Auto DevOps button if can not manage pipelines' do - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).not_to have_link('Enable Auto DevOps') expect(page).not_to have_link('Auto DevOps enabled') end end it 'no Kubernetes cluster button if can not manage clusters' do - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).not_to have_link('Add Kubernetes cluster') expect(page).not_to have_link('Kubernetes configured') end @@ -151,59 +151,59 @@ describe 'Projects > Show > User sees setup shortcut buttons' do sign_in(user) end - context 'Readme button' do + context 'README button' do before do allow(Project).to receive(:find_by_full_path) .with(project.full_path, follow_redirects: true) .and_return(project) end - context 'when the project has a populated Readme' do - it 'show the "Readme" anchor' do + context 'when the project has a populated README' do + it 'show the "README" anchor' do visit project_path(project) expect(project.repository.readme).not_to be_nil - page.within('.project-stats') do - expect(page).not_to have_link('Add Readme', href: presenter.add_readme_path) - expect(page).to have_link('Readme', href: presenter.readme_path) + page.within('.project-buttons') do + expect(page).not_to have_link('Add README', href: presenter.add_readme_path) + expect(page).to have_link('README', href: presenter.readme_path) end end - context 'when the project has an empty Readme' do - it 'show the "Readme" anchor' do + context 'when the project has an empty README' do + it 'show the "README" anchor' do allow(project.repository).to receive(:readme).and_return(fake_blob(path: 'README.md', data: '', size: 0)) visit project_path(project) - page.within('.project-stats') do - expect(page).not_to have_link('Add Readme', href: presenter.add_readme_path) - expect(page).to have_link('Readme', href: presenter.readme_path) + page.within('.project-buttons') do + expect(page).not_to have_link('Add README', href: presenter.add_readme_path) + expect(page).to have_link('README', href: presenter.readme_path) end end end end - context 'when the project does not have a Readme' do - it 'shows the "Add Readme" button' do + context 'when the project does not have a README' do + it 'shows the "Add README" button' do allow(project.repository).to receive(:readme).and_return(nil) visit project_path(project) - page.within('.project-stats') do - expect(page).to have_link('Add Readme', href: presenter.add_readme_path) + page.within('.project-buttons') do + expect(page).to have_link('Add README', href: presenter.add_readme_path) end end end end - it 'no "Add Changelog" button if the project already has a changelog' do + it 'no "Add CHANGELOG" button if the project already has a changelog' do visit project_path(project) expect(project.repository.changelog).not_to be_nil - page.within('.project-stats') do - expect(page).not_to have_link('Add Changelog') + page.within('.project-buttons') do + expect(page).not_to have_link('Add CHANGELOG') end end @@ -212,18 +212,18 @@ describe 'Projects > Show > User sees setup shortcut buttons' do expect(project.repository.license_blob).not_to be_nil - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).not_to have_link('Add license') end end - it 'no "Add Contribution guide" button if the project already has a contribution guide' do + it 'no "Add CONTRIBUTING" button if the project already has a contribution guide' do visit project_path(project) expect(project.repository.contribution_guide).not_to be_nil - page.within('.project-stats') do - expect(page).not_to have_link('Add Contribution guide') + page.within('.project-buttons') do + expect(page).not_to have_link('Add CONTRIBUTING') end end @@ -232,7 +232,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do it 'no "Set up CI/CD" button if the project has Auto DevOps enabled' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).not_to have_link('Set up CI/CD') end end @@ -246,7 +246,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do expect(project.repository.gitlab_ci_yml).to be_nil - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_link('Set up CI/CD', href: presenter.add_ci_yml_path) end end @@ -266,7 +266,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).not_to have_link('Set up CI/CD') end end @@ -278,7 +278,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do it '"Auto DevOps enabled" anchor linked to settings page' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_link('Auto DevOps enabled', href: project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) end end @@ -290,7 +290,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do it '"Enable Auto DevOps" button linked to settings page' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_link('Enable Auto DevOps', href: project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) end end @@ -302,7 +302,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do expect(page).to have_selector('.js-autodevops-banner') - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).not_to have_link('Enable Auto DevOps') expect(page).not_to have_link('Auto DevOps enabled') end @@ -323,7 +323,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).not_to have_link('Enable Auto DevOps') expect(page).not_to have_link('Auto DevOps enabled') end @@ -335,7 +335,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do it '"Add Kubernetes cluster" button linked to clusters page' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_link('Add Kubernetes cluster', href: new_project_cluster_path(project)) end end @@ -345,7 +345,7 @@ describe 'Projects > Show > User sees setup shortcut buttons' do visit project_path(project) - page.within('.project-stats') do + page.within('.project-buttons') do expect(page).to have_link('Kubernetes configured', href: project_cluster_path(project, cluster)) end end diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb index 571104d3467..48a0d675f2d 100644 --- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb @@ -85,7 +85,7 @@ describe "User creates wiki page" do expect(current_path).to eq(project_wiki_path(project, "test")) page.within(:css, ".nav-text") do - expect(page).to have_content("Test").and have_content("Create Page") + expect(page).to have_content("test").and have_content("Create Page") end click_link("Home") @@ -97,7 +97,7 @@ describe "User creates wiki page" do expect(current_path).to eq(project_wiki_path(project, "api")) page.within(:css, ".nav-text") do - expect(page).to have_content("Create").and have_content("Api") + expect(page).to have_content("Create").and have_content("api") end click_link("Home") @@ -109,7 +109,7 @@ describe "User creates wiki page" do expect(current_path).to eq(project_wiki_path(project, "raketasks")) page.within(:css, ".nav-text") do - expect(page).to have_content("Create").and have_content("Rake") + expect(page).to have_content("Create").and have_content("rake") end end @@ -200,7 +200,7 @@ describe "User creates wiki page" do click_button("Create page") end - expect(page).to have_content("Foo") + expect(page).to have_content("foo") .and have_content("Last edited by #{user.name}") .and have_content("My awesome wiki!") end @@ -215,7 +215,7 @@ describe "User creates wiki page" do end # Commit message field should have correct value. - expect(page).to have_field("wiki[message]", with: "Create spaces in the name") + expect(page).to have_field("wiki[message]", with: "Create Spaces in the name") page.within(".wiki-form") do fill_in(:wiki_content, with: "My awesome wiki!") @@ -246,7 +246,7 @@ describe "User creates wiki page" do click_button("Create page") end - expect(page).to have_content("Hyphens in the name") + expect(page).to have_content("hyphens in the name") .and have_content("Last edited by #{user.name}") .and have_content("My awesome wiki!") end @@ -293,7 +293,7 @@ describe "User creates wiki page" do click_button("Create page") end - expect(page).to have_content("Foo") + expect(page).to have_content("foo") .and have_content("Last edited by #{user.name}") .and have_content("My awesome wiki!") end @@ -311,7 +311,7 @@ describe "User creates wiki page" do it 'renders a default sidebar when there is no customized sidebar' do visit(project_wikis_path(project)) - expect(page).to have_content('Another') + expect(page).to have_content('another') expect(page).to have_content('More Pages') end diff --git a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb index 7ad7fec922a..f76e577b0d6 100644 --- a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb @@ -39,11 +39,11 @@ describe 'User updates wiki page' do end expect(current_path).to include('one/two/three-test') - expect(find('.wiki-pages')).to have_content('Three') + expect(find('.wiki-pages')).to have_content('three') - first(:link, text: 'Three').click + first(:link, text: 'three').click - expect(find('.nav-text')).to have_content('Three') + expect(find('.nav-text')).to have_content('three') click_on('Edit') diff --git a/spec/features/projects/wiki/user_views_wiki_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_page_spec.rb index 3c93d71ab00..d4691b669c1 100644 --- a/spec/features/projects/wiki/user_views_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_views_wiki_page_spec.rb @@ -38,7 +38,7 @@ describe 'User views a wiki page' do it 'shows the history of a page that has a path', :js do expect(current_path).to include('one/two/three-test') - first(:link, text: 'Three').click + first(:link, text: 'three').click click_on('Page history') expect(current_path).to include('one/two/three-test') @@ -50,11 +50,11 @@ describe 'User views a wiki page' do it 'shows an old version of a page', :js do expect(current_path).to include('one/two/three-test') - expect(find('.wiki-pages')).to have_content('Three') + expect(find('.wiki-pages')).to have_content('three') - first(:link, text: 'Three').click + first(:link, text: 'three').click - expect(find('.nav-text')).to have_content('Three') + expect(find('.nav-text')).to have_content('three') click_on('Edit') diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 0add129dde2..b56bb272b46 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -277,7 +277,7 @@ describe 'Project' do end end - context 'for subgroups', :js do + context 'for subgroups', :js, :nested_groups do let(:group) { create(:group) } let(:subgroup) { create(:group, parent: group) } let(:project) { create(:project, :repository, group: subgroup) } diff --git a/spec/features/tags/master_views_tags_spec.rb b/spec/features/tags/master_views_tags_spec.rb index 3f4fe549f3e..36cfeb5ed84 100644 --- a/spec/features/tags/master_views_tags_spec.rb +++ b/spec/features/tags/master_views_tags_spec.rb @@ -13,7 +13,7 @@ describe 'Maintainer views tags' do before do visit project_path(project) - click_on 'Add Readme' + click_on 'Add README' fill_in :commit_message, with: 'Add a README file', visible: true click_button 'Commit changes' visit project_tags_path(project) diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb index f545da3aee4..8975ea0f063 100644 --- a/spec/finders/group_members_finder_spec.rb +++ b/spec/finders/group_members_finder_spec.rb @@ -19,7 +19,7 @@ describe GroupMembersFinder, '#execute' do end it 'returns members for nested group', :nested_groups do - group.add_maintainer(user2) + group.add_developer(user2) nested_group.request_access(user4) member1 = group.add_maintainer(user1) member3 = nested_group.add_maintainer(user2) diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 515f6f70b99..80f7232f282 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -640,4 +640,131 @@ describe IssuesFinder do end end end + + describe '#use_subquery_for_search?' do + let(:finder) { described_class.new(nil, params) } + + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + stub_feature_flags(use_subquery_for_group_issues_search: true) + end + + context 'when there is no search param' do + let(:params) { { attempt_group_search_optimizations: true } } + + it 'returns false' do + expect(finder.use_subquery_for_search?).to be_falsey + end + end + + context 'when the database is not Postgres' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + end + + it 'returns false' do + expect(finder.use_subquery_for_search?).to be_falsey + end + end + + context 'when the attempt_group_search_optimizations param is falsey' do + let(:params) { { search: 'foo' } } + + it 'returns false' do + expect(finder.use_subquery_for_search?).to be_falsey + end + end + + context 'when the use_subquery_for_group_issues_search flag is disabled' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + before do + stub_feature_flags(use_subquery_for_group_issues_search: false) + end + + it 'returns false' do + expect(finder.use_subquery_for_search?).to be_falsey + end + end + + context 'when all conditions are met' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + it 'returns true' do + expect(finder.use_subquery_for_search?).to be_truthy + end + end + end + + describe '#use_cte_for_search?' do + let(:finder) { described_class.new(nil, params) } + + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + stub_feature_flags(use_cte_for_group_issues_search: true) + stub_feature_flags(use_subquery_for_group_issues_search: false) + end + + context 'when there is no search param' do + let(:params) { { attempt_group_search_optimizations: true } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when the database is not Postgres' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + end + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when the attempt_group_search_optimizations param is falsey' do + let(:params) { { search: 'foo' } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when the use_cte_for_group_issues_search flag is disabled' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + before do + stub_feature_flags(use_cte_for_group_issues_search: false) + end + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when use_subquery_for_search? is true' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + before do + stub_feature_flags(use_subquery_for_group_issues_search: true) + end + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when all conditions are met' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + end + end + end end diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb new file mode 100644 index 00000000000..60d02b12054 --- /dev/null +++ b/spec/finders/projects/serverless/functions_finder_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::Serverless::FunctionsFinder do + include KubernetesHelpers + include ReactiveCachingHelpers + + let(:user) { create(:user) } + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:service) { cluster.platform_kubernetes } + let(:project) { cluster.project} + + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: cluster.cluster_project.project) + end + + before do + project.add_maintainer(user) + end + + describe 'retrieve data from knative' do + it 'does not have knative installed' do + expect(described_class.new(project.clusters).execute).to be_empty + end + + context 'has knative installed' do + let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } + + it 'there are no functions' do + expect(described_class.new(project.clusters).execute).to be_empty + end + + it 'there are functions', :use_clean_rails_memory_store_caching do + stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"]) + + expect(described_class.new(project.clusters).execute).not_to be_empty + end + end + end + + describe 'verify if knative is installed' do + context 'knative is not installed' do + it 'does not have knative installed' do + expect(described_class.new(project.clusters).installed?).to be false + end + end + + context 'knative is installed' do + let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } + + it 'does have knative installed' do + expect(described_class.new(project.clusters).installed?).to be true + end + end + end +end diff --git a/spec/fixtures/api/schemas/entities/issue_board.json b/spec/fixtures/api/schemas/entities/issue_board.json index 3e252ddd13c..f7b270ffa8d 100644 --- a/spec/fixtures/api/schemas/entities/issue_board.json +++ b/spec/fixtures/api/schemas/entities/issue_board.json @@ -9,7 +9,7 @@ "project_id": { "type": "integer" }, "relative_position": { "type": ["integer", "null"] }, "time_estimate": { "type": "integer" }, - "weight": { "type": "integer" }, + "weight": { "type": ["integer", "null"] }, "project": { "type": "object", "properties": { diff --git a/spec/fixtures/api/schemas/entities/issue_boards.json b/spec/fixtures/api/schemas/entities/issue_boards.json new file mode 100644 index 00000000000..0ac1d9468c8 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/issue_boards.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required" : [ + "issues", + "size" + ], + "properties" : { + "issues": { + "type": "array", + "items": { "$ref": "issue_board.json" } + }, + "size": { "type": "integer" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/bfg_object_map.txt b/spec/fixtures/bfg_object_map.txt new file mode 100644 index 00000000000..c60171d8770 --- /dev/null +++ b/spec/fixtures/bfg_object_map.txt @@ -0,0 +1 @@ +f1d2d2f924e986ac86fdf7b36c94bcdf32beec15 e242ed3bffccdf271b7fbaf34ed72d089537b42f diff --git a/spec/fixtures/security-reports/feature-branch/gl-dependency-scanning-report.json b/spec/fixtures/security-reports/feature-branch/gl-dependency-scanning-report.json index 4b47e259c0f..ce66f562175 100644 --- a/spec/fixtures/security-reports/feature-branch/gl-dependency-scanning-report.json +++ b/spec/fixtures/security-reports/feature-branch/gl-dependency-scanning-report.json @@ -1,46 +1,178 @@ [ { - "priority": "Unknown", - "file": "pom.xml", - "cve": "CVE-2012-4387", - "url": "http://struts.apache.org/docs/s2-011.html", - "message": "Long parameter name DoS for org.apache.struts/struts2-core", - "tools": [ - "gemnasium" + "category": "dependency_scanning", + "name": "io.netty/netty - CVE-2014-3488", + "message": "DoS by CPU exhaustion when using malicious SSL packets", + "cve": "app/pom.xml:io.netty/netty@3.9.1.Final:CVE-2014-3488", + "severity": "Unknown", + "solution": "Upgrade to the latest version", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "app/pom.xml", + "dependency": { + "package": { + "name": "io.netty/netty" + }, + "version": "3.9.1.Final" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-d1bf36d9-9f07-46cd-9cfc-8675338ada8f", + "value": "d1bf36d9-9f07-46cd-9cfc-8675338ada8f", + "url": "https://deps.sec.gitlab.com/packages/maven/io.netty/netty/versions/3.9.1.Final/advisories" + }, + { + "type": "cve", + "name": "CVE-2014-3488", + "value": "CVE-2014-3488", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-3488" + } + ], + "links": [ + { + "url": "https://bugzilla.redhat.com/CVE-2014-3488" + }, + { + "url": "http://netty.io/news/2014/06/11/3.html" + }, + { + "url": "https://github.com/netty/netty/issues/2562" + } ], + "priority": "Unknown", + "file": "app/pom.xml", + "url": "https://bugzilla.redhat.com/CVE-2014-3488", "tool": "gemnasium" }, { - "priority": "Unknown", - "file": "pom.xml", - "cve": "CVE-2013-1966", - "url": "http://struts.apache.org/docs/s2-014.html", - "message": "Remote command execution due to flaw in the includeParams attribute of URL and Anchor tags for org.apache.struts/struts2-core", - "tools": [ - "gemnasium" + "category": "dependency_scanning", + "name": "Django - CVE-2017-12794", + "message": "Possible XSS in traceback section of technical 500 debug page", + "cve": "app/requirements.txt:Django@1.11.3:CVE-2017-12794", + "severity": "Unknown", + "solution": "Upgrade to latest version or apply patch.", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "app/requirements.txt", + "dependency": { + "package": { + "name": "Django" + }, + "version": "1.11.3" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-6162a015-8635-4a15-8d7c-dc9321db366f", + "value": "6162a015-8635-4a15-8d7c-dc9321db366f", + "url": "https://deps.sec.gitlab.com/packages/pypi/Django/versions/1.11.3/advisories" + }, + { + "type": "cve", + "name": "CVE-2017-12794", + "value": "CVE-2017-12794", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-12794" + } + ], + "links": [ + { + "url": "https://www.djangoproject.com/weblog/2017/sep/05/security-releases/" + } ], + "priority": "Unknown", + "file": "app/requirements.txt", + "url": "https://www.djangoproject.com/weblog/2017/sep/05/security-releases/", "tool": "gemnasium" }, { - "priority": "Unknown", - "file": "pom.xml", - "cve": "CVE-2013-2115", - "url": "http://struts.apache.org/docs/s2-014.html", - "message": "Remote command execution due to flaw in the includeParams attribute of URL and Anchor tags for org.apache.struts/struts2-core", - "tools": [ - "gemnasium" + "category": "dependency_scanning", + "name": "nokogiri - USN-3424-1", + "message": "Vulnerabilities in libxml2", + "cve": "rails/Gemfile.lock:nokogiri@1.8.0:USN-3424-1", + "severity": "Unknown", + "solution": "Upgrade to latest version.", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "rails/Gemfile.lock", + "dependency": { + "package": { + "name": "nokogiri" + }, + "version": "1.8.0" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-06565b64-486d-4326-b906-890d9915804d", + "value": "06565b64-486d-4326-b906-890d9915804d", + "url": "https://deps.sec.gitlab.com/packages/gem/nokogiri/versions/1.8.0/advisories" + }, + { + "type": "usn", + "name": "USN-3424-1", + "value": "USN-3424-1", + "url": "https://usn.ubuntu.com/3424-1/" + } + ], + "links": [ + { + "url": "https://github.com/sparklemotion/nokogiri/issues/1673" + } ], + "priority": "Unknown", + "file": "rails/Gemfile.lock", + "url": "https://github.com/sparklemotion/nokogiri/issues/1673", "tool": "gemnasium" }, { - "priority": "Unknown", - "file": "pom.xml", - "cve": "CVE-2013-2134", - "url": "http://struts.apache.org/docs/s2-015.html", - "message": "Arbitrary OGNL code execution via unsanitized wildcard matching for org.apache.struts/struts2-core", - "tools": [ - "gemnasium" + "category": "dependency_scanning", + "name": "ffi - CVE-2018-1000201", + "message": "ruby-ffi DDL loading issue on Windows OS", + "cve": "ffi:1.9.18:CVE-2018-1000201", + "severity": "High", + "solution": "upgrade to \u003e= 1.9.24", + "scanner": { + "id": "bundler_audit", + "name": "bundler-audit" + }, + "location": { + "file": "sast-sample-rails/Gemfile.lock", + "dependency": { + "package": { + "name": "ffi" + }, + "version": "1.9.18" + } + }, + "identifiers": [ + { + "type": "cve", + "name": "CVE-2018-1000201", + "value": "CVE-2018-1000201", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-1000201" + } ], - "tool": "gemnasium" + "links": [ + { + "url": "https://github.com/ffi/ffi/releases/tag/1.9.24" + } + ], + "priority": "High", + "file": "sast-sample-rails/Gemfile.lock", + "url": "https://github.com/ffi/ffi/releases/tag/1.9.24", + "tool": "bundler_audit" } ] diff --git a/spec/fixtures/security-reports/master/gl-dependency-scanning-report.json b/spec/fixtures/security-reports/master/gl-dependency-scanning-report.json index b4e4e8e7dd5..ce66f562175 100644 --- a/spec/fixtures/security-reports/master/gl-dependency-scanning-report.json +++ b/spec/fixtures/security-reports/master/gl-dependency-scanning-report.json @@ -1,35 +1,178 @@ [ { - "priority": "Unknown", - "file": "pom.xml", - "cve": "CVE-2012-4386", - "url": "http://struts.apache.org/docs/s2-010.html", - "message": "CSRF protection bypass for org.apache.struts/struts2-core", - "tools": [ - "gemnasium" + "category": "dependency_scanning", + "name": "io.netty/netty - CVE-2014-3488", + "message": "DoS by CPU exhaustion when using malicious SSL packets", + "cve": "app/pom.xml:io.netty/netty@3.9.1.Final:CVE-2014-3488", + "severity": "Unknown", + "solution": "Upgrade to the latest version", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "app/pom.xml", + "dependency": { + "package": { + "name": "io.netty/netty" + }, + "version": "3.9.1.Final" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-d1bf36d9-9f07-46cd-9cfc-8675338ada8f", + "value": "d1bf36d9-9f07-46cd-9cfc-8675338ada8f", + "url": "https://deps.sec.gitlab.com/packages/maven/io.netty/netty/versions/3.9.1.Final/advisories" + }, + { + "type": "cve", + "name": "CVE-2014-3488", + "value": "CVE-2014-3488", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-3488" + } + ], + "links": [ + { + "url": "https://bugzilla.redhat.com/CVE-2014-3488" + }, + { + "url": "http://netty.io/news/2014/06/11/3.html" + }, + { + "url": "https://github.com/netty/netty/issues/2562" + } ], + "priority": "Unknown", + "file": "app/pom.xml", + "url": "https://bugzilla.redhat.com/CVE-2014-3488", "tool": "gemnasium" }, { - "priority": "Unknown", - "file": "pom.xml", - "cve": "CVE-2012-4387", - "url": "http://struts.apache.org/docs/s2-011.html", - "message": "Long parameter name DoS for org.apache.struts/struts2-core", - "tools": [ - "gemnasium" + "category": "dependency_scanning", + "name": "Django - CVE-2017-12794", + "message": "Possible XSS in traceback section of technical 500 debug page", + "cve": "app/requirements.txt:Django@1.11.3:CVE-2017-12794", + "severity": "Unknown", + "solution": "Upgrade to latest version or apply patch.", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "app/requirements.txt", + "dependency": { + "package": { + "name": "Django" + }, + "version": "1.11.3" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-6162a015-8635-4a15-8d7c-dc9321db366f", + "value": "6162a015-8635-4a15-8d7c-dc9321db366f", + "url": "https://deps.sec.gitlab.com/packages/pypi/Django/versions/1.11.3/advisories" + }, + { + "type": "cve", + "name": "CVE-2017-12794", + "value": "CVE-2017-12794", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-12794" + } ], + "links": [ + { + "url": "https://www.djangoproject.com/weblog/2017/sep/05/security-releases/" + } + ], + "priority": "Unknown", + "file": "app/requirements.txt", + "url": "https://www.djangoproject.com/weblog/2017/sep/05/security-releases/", "tool": "gemnasium" }, { - "priority": "Unknown", - "file": "pom.xml", - "cve": "CVE-2013-1966", - "url": "http://struts.apache.org/docs/s2-014.html", - "message": "Remote command execution due to flaw in the includeParams attribute of URL and Anchor tags for org.apache.struts/struts2-core", - "tools": [ - "gemnasium" + "category": "dependency_scanning", + "name": "nokogiri - USN-3424-1", + "message": "Vulnerabilities in libxml2", + "cve": "rails/Gemfile.lock:nokogiri@1.8.0:USN-3424-1", + "severity": "Unknown", + "solution": "Upgrade to latest version.", + "scanner": { + "id": "gemnasium", + "name": "Gemnasium" + }, + "location": { + "file": "rails/Gemfile.lock", + "dependency": { + "package": { + "name": "nokogiri" + }, + "version": "1.8.0" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-06565b64-486d-4326-b906-890d9915804d", + "value": "06565b64-486d-4326-b906-890d9915804d", + "url": "https://deps.sec.gitlab.com/packages/gem/nokogiri/versions/1.8.0/advisories" + }, + { + "type": "usn", + "name": "USN-3424-1", + "value": "USN-3424-1", + "url": "https://usn.ubuntu.com/3424-1/" + } ], + "links": [ + { + "url": "https://github.com/sparklemotion/nokogiri/issues/1673" + } + ], + "priority": "Unknown", + "file": "rails/Gemfile.lock", + "url": "https://github.com/sparklemotion/nokogiri/issues/1673", "tool": "gemnasium" + }, + { + "category": "dependency_scanning", + "name": "ffi - CVE-2018-1000201", + "message": "ruby-ffi DDL loading issue on Windows OS", + "cve": "ffi:1.9.18:CVE-2018-1000201", + "severity": "High", + "solution": "upgrade to \u003e= 1.9.24", + "scanner": { + "id": "bundler_audit", + "name": "bundler-audit" + }, + "location": { + "file": "sast-sample-rails/Gemfile.lock", + "dependency": { + "package": { + "name": "ffi" + }, + "version": "1.9.18" + } + }, + "identifiers": [ + { + "type": "cve", + "name": "CVE-2018-1000201", + "value": "CVE-2018-1000201", + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-1000201" + } + ], + "links": [ + { + "url": "https://github.com/ffi/ffi/releases/tag/1.9.24" + } + ], + "priority": "High", + "file": "sast-sample-rails/Gemfile.lock", + "url": "https://github.com/ffi/ffi/releases/tag/1.9.24", + "tool": "bundler_audit" } ] diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml new file mode 100644 index 00000000000..6d73977a891 --- /dev/null +++ b/spec/frontend/.eslintrc.yml @@ -0,0 +1,9 @@ +--- +env: + jest/globals: true +plugins: +- jest +settings: + import/resolver: + jest: + jestConfigFile: "config/jest.config.js" diff --git a/spec/frontend/dummy_spec.js b/spec/frontend/dummy_spec.js new file mode 100644 index 00000000000..2bfef25e9c6 --- /dev/null +++ b/spec/frontend/dummy_spec.js @@ -0,0 +1 @@ +it('does nothing', () => {}); diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 976b6c312b4..a857b7646b2 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -471,6 +471,31 @@ describe ProjectsHelper do end end + describe 'link_to_bfg' do + subject { helper.link_to_bfg } + + it 'generates a hardcoded link to the BFG Repo-Cleaner' do + result = helper.link_to_bfg + doc = Nokogiri::HTML.fragment(result) + + expect(doc.children.size).to eq(1) + + link = doc.children.first + + aggregate_failures do + expect(result).to be_html_safe + + expect(link.name).to eq('a') + expect(link[:target]).to eq('_blank') + expect(link[:rel]).to eq('noopener noreferrer') + expect(link[:href]).to eq('https://rtyley.github.io/bfg-repo-cleaner/') + expect(link.inner_html).to eq('BFG') + + expect(result).to be_html_safe + end + end + end + describe '#legacy_render_context' do it 'returns the redcarpet engine' do params = { legacy_render: '1' } diff --git a/spec/helpers/sorting_helper_spec.rb b/spec/helpers/sorting_helper_spec.rb new file mode 100644 index 00000000000..cba0d93e144 --- /dev/null +++ b/spec/helpers/sorting_helper_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe SortingHelper do + include ApplicationHelper + include IconsHelper + + describe '#issuable_sort_option_title' do + it 'returns correct title for issuable_sort_option_overrides key' do + expect(issuable_sort_option_title('created_asc')).to eq('Created date') + end + + it 'returns correct title for a valid sort value' do + expect(issuable_sort_option_title('priority')).to eq('Priority') + end + + it 'returns nil for invalid sort value' do + expect(issuable_sort_option_title('invalid_key')).to eq(nil) + end + end + + describe '#issuable_sort_direction_button' do + before do + allow(self).to receive(:request).and_return(double(path: 'http://test.com', query_parameters: {})) + end + + it 'returns icon with sort-highest when sort is created_date' do + expect(issuable_sort_direction_button('created_date')).to include('sort-highest') + end + + it 'returns icon with sort-lowest when sort is asc' do + expect(issuable_sort_direction_button('created_asc')).to include('sort-lowest') + end + + it 'returns icon with sort-lowest when sorting by milestone' do + expect(issuable_sort_direction_button('milestone')).to include('sort-lowest') + end + + it 'returns icon with sort-lowest when sorting by due_date' do + expect(issuable_sort_direction_button('due_date')).to include('sort-lowest') + end + end +end diff --git a/spec/initializers/lograge_spec.rb b/spec/initializers/lograge_spec.rb new file mode 100644 index 00000000000..af54a777373 --- /dev/null +++ b/spec/initializers/lograge_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'lograge', type: :request do + let(:headers) { { 'X-Request-ID' => 'new-correlation-id' } } + + context 'for API requests' do + subject { get("/api/v4/endpoint", {}, headers) } + + it 'logs to api_json log' do + # we assert receiving parameters by grape logger + expect_any_instance_of(Gitlab::GrapeLogging::Formatters::LogrageWithTimestamp).to receive(:call) + .with(anything, anything, anything, a_hash_including("correlation_id" => "new-correlation-id")) + .and_call_original + + subject + end + end + + context 'for Controller requests' do + subject { get("/", {}, headers) } + + it 'logs to production_json log' do + # formatter receives a hash with correlation id + expect(Lograge.formatter).to receive(:call) + .with(a_hash_including("correlation_id" => "new-correlation-id")) + .and_call_original + + # a log file receives a line with correlation id + expect(Lograge.logger).to receive(:send) + .with(anything, include('"correlation_id":"new-correlation-id"')) + .and_call_original + + subject + end + end +end diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 091edf13cfe..46f72214831 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -123,7 +123,7 @@ describe('Api', () => { }); }); - describe('mergerequest', () => { + describe('projectMergeRequest', () => { it('fetches a merge request', done => { const projectPath = 'abc'; const mergeRequestId = '123456'; @@ -132,7 +132,7 @@ describe('Api', () => { title: 'test', }); - Api.mergeRequest(projectPath, mergeRequestId) + Api.projectMergeRequest(projectPath, mergeRequestId) .then(({ data }) => { expect(data.title).toBe('test'); }) @@ -141,7 +141,7 @@ describe('Api', () => { }); }); - describe('mergerequest changes', () => { + describe('projectMergeRequestChanges', () => { it('fetches the changes of a merge request', done => { const projectPath = 'abc'; const mergeRequestId = '123456'; @@ -150,7 +150,7 @@ describe('Api', () => { title: 'test', }); - Api.mergeRequestChanges(projectPath, mergeRequestId) + Api.projectMergeRequestChanges(projectPath, mergeRequestId) .then(({ data }) => { expect(data.title).toBe('test'); }) @@ -159,7 +159,7 @@ describe('Api', () => { }); }); - describe('mergerequest versions', () => { + describe('projectMergeRequestVersions', () => { it('fetches the versions of a merge request', done => { const projectPath = 'abc'; const mergeRequestId = '123456'; @@ -170,7 +170,7 @@ describe('Api', () => { }, ]); - Api.mergeRequestVersions(projectPath, mergeRequestId) + Api.projectMergeRequestVersions(projectPath, mergeRequestId) .then(({ data }) => { expect(data.length).toBe(1); expect(data[0].id).toBe(123); @@ -180,6 +180,23 @@ describe('Api', () => { }); }); + describe('projectRunners', () => { + it('fetches the runners of a project', done => { + const projectPath = 7; + const params = { scope: 'active' }; + const mockData = [{ id: 4 }]; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/runners`; + mock.onGet(expectedUrl, { params }).reply(200, mockData); + + Api.projectRunners(projectPath, { params }) + .then(({ data }) => { + expect(data).toEqual(mockData); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('newLabel', () => { it('creates a new label', done => { const namespace = 'some namespace'; diff --git a/spec/javascripts/clusters/components/applications_spec.js b/spec/javascripts/clusters/components/applications_spec.js index 928bf70f3a2..e46edec9abb 100644 --- a/spec/javascripts/clusters/components/applications_spec.js +++ b/spec/javascripts/clusters/components/applications_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import applications from '~/clusters/components/applications.vue'; +import { CLUSTER_TYPE } from '~/clusters/constants'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Applications', () => { @@ -14,9 +15,10 @@ describe('Applications', () => { vm.$destroy(); }); - describe('', () => { + describe('Project cluster applications', () => { beforeEach(() => { vm = mountComponent(Applications, { + type: CLUSTER_TYPE.PROJECT, applications: { helm: { title: 'Helm Tiller' }, ingress: { title: 'Ingress' }, @@ -30,31 +32,76 @@ describe('Applications', () => { }); it('renders a row for Helm Tiller', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-helm')).toBeDefined(); + expect(vm.$el.querySelector('.js-cluster-application-row-helm')).not.toBeNull(); }); it('renders a row for Ingress', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).toBeDefined(); + expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).not.toBeNull(); }); it('renders a row for Cert-Manager', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).toBeDefined(); + expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); }); it('renders a row for Prometheus', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).toBeDefined(); + expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); }); it('renders a row for GitLab Runner', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-runner')).toBeDefined(); + expect(vm.$el.querySelector('.js-cluster-application-row-runner')).not.toBeNull(); }); it('renders a row for Jupyter', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-jupyter')).not.toBe(null); + expect(vm.$el.querySelector('.js-cluster-application-row-jupyter')).not.toBeNull(); }); it('renders a row for Knative', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBe(null); + expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); + }); + }); + + describe('Group cluster applications', () => { + beforeEach(() => { + vm = mountComponent(Applications, { + type: CLUSTER_TYPE.GROUP, + applications: { + helm: { title: 'Helm Tiller' }, + ingress: { title: 'Ingress' }, + cert_manager: { title: 'Cert-Manager' }, + runner: { title: 'GitLab Runner' }, + prometheus: { title: 'Prometheus' }, + jupyter: { title: 'JupyterHub' }, + knative: { title: 'Knative' }, + }, + }); + }); + + it('renders a row for Helm Tiller', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-helm')).not.toBeNull(); + }); + + it('renders a row for Ingress', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).not.toBeNull(); + }); + + it('renders a row for Cert-Manager', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); + }); + + it('renders a row for Prometheus', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).toBeNull(); + }); + + it('renders a row for GitLab Runner', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-runner')).toBeNull(); + }); + + it('renders a row for Jupyter', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-jupyter')).toBeNull(); + }); + + it('renders a row for Knative', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-knative')).toBeNull(); }); }); diff --git a/spec/javascripts/diffs/components/app_spec.js b/spec/javascripts/diffs/components/app_spec.js index 1a0b7612ee9..1e2f7ff4fd8 100644 --- a/spec/javascripts/diffs/components/app_spec.js +++ b/spec/javascripts/diffs/components/app_spec.js @@ -13,6 +13,8 @@ describe('diffs/components/app', () => { beforeEach(() => { // setup globals (needed for component to mount :/) window.mrTabs = jasmine.createSpyObj('mrTabs', ['resetViewContainer']); + window.mrTabs.expandViewContainer = jasmine.createSpy(); + window.location.hash = 'ABC_123'; // setup component const store = createDiffsStore(); @@ -39,4 +41,15 @@ describe('diffs/components/app', () => { it('does not show commit info', () => { expect(vm.$el).not.toContainElement('.blob-commit-info'); }); + + it('sets highlighted row if hash exists in location object', done => { + vm.$props.shouldShow = true; + + vm.$nextTick() + .then(() => { + expect(vm.$store.state.diffs.highlightedRow).toBe('ABC_123'); + }) + .then(done) + .catch(done.fail); + }); }); diff --git a/spec/javascripts/diffs/components/diff_file_header_spec.js b/spec/javascripts/diffs/components/diff_file_header_spec.js index 9530b50c729..b77907ff26f 100644 --- a/spec/javascripts/diffs/components/diff_file_header_spec.js +++ b/spec/javascripts/diffs/components/diff_file_header_spec.js @@ -464,7 +464,11 @@ describe('diff_file_header', () => { propsCopy.addMergeRequestButtons = true; propsCopy.diffFile.deleted_file = true; - const discussionGetter = () => [diffDiscussionMock]; + const discussionGetter = () => [ + { + ...diffDiscussionMock, + }, + ]; const notesModuleMock = notesModule(); notesModuleMock.getters.discussions = discussionGetter; vm = mountComponentWithStore(Component, { diff --git a/spec/javascripts/diffs/components/diff_file_spec.js b/spec/javascripts/diffs/components/diff_file_spec.js index 51bb4807960..1af49282c36 100644 --- a/spec/javascripts/diffs/components/diff_file_spec.js +++ b/spec/javascripts/diffs/components/diff_file_spec.js @@ -74,6 +74,32 @@ describe('DiffFile', () => { }); }); + it('should be collapsed for renamed files', done => { + vm.file.renderIt = true; + vm.file.collapsed = false; + vm.file.highlighted_diff_lines = null; + vm.file.renamed_file = true; + + vm.$nextTick(() => { + expect(vm.$el.innerText).not.toContain('This diff is collapsed'); + + done(); + }); + }); + + it('should be collapsed for mode changed files', done => { + vm.file.renderIt = true; + vm.file.collapsed = false; + vm.file.highlighted_diff_lines = null; + vm.file.mode_changed = true; + + vm.$nextTick(() => { + expect(vm.$el.innerText).not.toContain('This diff is collapsed'); + + done(); + }); + }); + it('should have loading icon while loading a collapsed diffs', done => { vm.file.collapsed = true; vm.isLoadingCollapsedDiff = true; diff --git a/spec/javascripts/diffs/components/diff_gutter_avatars_spec.js b/spec/javascripts/diffs/components/diff_gutter_avatars_spec.js index ad2605a5c5c..cdd30919b09 100644 --- a/spec/javascripts/diffs/components/diff_gutter_avatars_spec.js +++ b/spec/javascripts/diffs/components/diff_gutter_avatars_spec.js @@ -89,6 +89,35 @@ describe('DiffGutterAvatars', () => { expect(component.discussions[0].expanded).toEqual(false); component.$store.dispatch('setInitialNotes', []); }); + + it('forces expansion of all discussions', () => { + spyOn(component.$store, 'dispatch'); + + component.discussions[0].expanded = true; + component.discussions.push({ + ...component.discussions[0], + id: '123test', + expanded: false, + }); + + component.toggleDiscussions(); + + expect(component.$store.dispatch.calls.argsFor(0)).toEqual([ + 'toggleDiscussion', + { + discussionId: component.discussions[0].id, + forceExpanded: true, + }, + ]); + + expect(component.$store.dispatch.calls.argsFor(1)).toEqual([ + 'toggleDiscussion', + { + discussionId: component.discussions[1].id, + forceExpanded: true, + }, + ]); + }); }); }); diff --git a/spec/javascripts/diffs/components/diff_line_note_form_spec.js b/spec/javascripts/diffs/components/diff_line_note_form_spec.js index 81b66cf7c9b..b983dc35a57 100644 --- a/spec/javascripts/diffs/components/diff_line_note_form_spec.js +++ b/spec/javascripts/diffs/components/diff_line_note_form_spec.js @@ -62,6 +62,7 @@ describe('DiffLineNoteForm', () => { component.$nextTick(() => { expect(component.cancelCommentForm).toHaveBeenCalledWith({ lineCode: diffLines[0].line_code, + fileHash: component.diffFileHash, }); expect(component.resetAutoSave).toHaveBeenCalled(); diff --git a/spec/javascripts/diffs/components/diff_table_cell_spec.js b/spec/javascripts/diffs/components/diff_table_cell_spec.js new file mode 100644 index 00000000000..170e661beea --- /dev/null +++ b/spec/javascripts/diffs/components/diff_table_cell_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import store from '~/mr_notes/stores'; +import DiffTableCell from '~/diffs/components/diff_table_cell.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import diffFileMockData from '../mock_data/diff_file'; + +describe('DiffTableCell', () => { + const createComponent = options => + createComponentWithStore(Vue.extend(DiffTableCell), store, { + line: diffFileMockData.highlighted_diff_lines[0], + fileHash: diffFileMockData.file_hash, + contextLinesPath: 'contextLinesPath', + ...options, + }).$mount(); + + it('does not highlight row when isHighlighted prop is false', done => { + const vm = createComponent({ isHighlighted: false }); + + vm.$nextTick() + .then(() => { + expect(vm.$el.classList).not.toContain('hll'); + }) + .then(done) + .catch(done.fail); + }); + + it('highlights row when isHighlighted prop is true', done => { + const vm = createComponent({ isHighlighted: true }); + + vm.$nextTick() + .then(() => { + expect(vm.$el.classList).toContain('hll'); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/javascripts/diffs/components/inline_diff_table_row_spec.js b/spec/javascripts/diffs/components/inline_diff_table_row_spec.js new file mode 100644 index 00000000000..97926f6625e --- /dev/null +++ b/spec/javascripts/diffs/components/inline_diff_table_row_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import store from '~/mr_notes/stores'; +import InlineDiffTableRow from '~/diffs/components/inline_diff_table_row.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import diffFileMockData from '../mock_data/diff_file'; + +describe('InlineDiffTableRow', () => { + let vm; + const thisLine = diffFileMockData.highlighted_diff_lines[0]; + + beforeEach(() => { + vm = createComponentWithStore(Vue.extend(InlineDiffTableRow), store, { + line: thisLine, + fileHash: diffFileMockData.file_hash, + contextLinesPath: 'contextLinesPath', + isHighlighted: false, + }).$mount(); + }); + + it('does not add hll class to line content when line does not match highlighted row', done => { + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.line_content').classList).not.toContain('hll'); + }) + .then(done) + .catch(done.fail); + }); + + it('adds hll class to lineContent when line is the highlighted row', done => { + vm.$nextTick() + .then(() => { + vm.$store.state.diffs.highlightedRow = thisLine.line_code; + + return vm.$nextTick(); + }) + .then(() => { + expect(vm.$el.querySelector('.line_content').classList).toContain('hll'); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/javascripts/diffs/components/parallel_diff_table_row_spec.js b/spec/javascripts/diffs/components/parallel_diff_table_row_spec.js new file mode 100644 index 00000000000..311eaaaa7c8 --- /dev/null +++ b/spec/javascripts/diffs/components/parallel_diff_table_row_spec.js @@ -0,0 +1,85 @@ +import Vue from 'vue'; +import { createStore } from '~/mr_notes/stores'; +import ParallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import diffFileMockData from '../mock_data/diff_file'; + +describe('ParallelDiffTableRow', () => { + describe('when one side is empty', () => { + let vm; + const thisLine = diffFileMockData.parallel_diff_lines[0]; + const rightLine = diffFileMockData.parallel_diff_lines[0].right; + + beforeEach(() => { + vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), { + line: thisLine, + fileHash: diffFileMockData.file_hash, + contextLinesPath: 'contextLinesPath', + isHighlighted: false, + }).$mount(); + }); + + it('does not highlight non empty line content when line does not match highlighted row', done => { + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.line_content.right-side').classList).not.toContain('hll'); + }) + .then(done) + .catch(done.fail); + }); + + it('highlights nonempty line content when line is the highlighted row', done => { + vm.$nextTick() + .then(() => { + vm.$store.state.diffs.highlightedRow = rightLine.line_code; + + return vm.$nextTick(); + }) + .then(() => { + expect(vm.$el.querySelector('.line_content.right-side').classList).toContain('hll'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('when both sides have content', () => { + let vm; + const thisLine = diffFileMockData.parallel_diff_lines[2]; + const rightLine = diffFileMockData.parallel_diff_lines[2].right; + + beforeEach(() => { + vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), { + line: thisLine, + fileHash: diffFileMockData.file_hash, + contextLinesPath: 'contextLinesPath', + isHighlighted: false, + }).$mount(); + }); + + it('does not highlight either line when line does not match highlighted row', done => { + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.line_content.right-side').classList).not.toContain('hll'); + expect(vm.$el.querySelector('.line_content.left-side').classList).not.toContain('hll'); + }) + .then(done) + .catch(done.fail); + }); + + it('adds hll class to lineContent when line is the highlighted row', done => { + vm.$nextTick() + .then(() => { + vm.$store.state.diffs.highlightedRow = rightLine.line_code; + + return vm.$nextTick(); + }) + .then(() => { + expect(vm.$el.querySelector('.line_content.right-side').classList).toContain('hll'); + expect(vm.$el.querySelector('.line_content.left-side').classList).toContain('hll'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/diffs/mock_data/diff_discussions.js b/spec/javascripts/diffs/mock_data/diff_discussions.js index 5ffe5a366ba..44313caba29 100644 --- a/spec/javascripts/diffs/mock_data/diff_discussions.js +++ b/spec/javascripts/diffs/mock_data/diff_discussions.js @@ -489,8 +489,6 @@ export default { diff_discussion: true, truncated_diff_lines: '<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="1">\n1\n</td>\n<td class="line_content new noteable_line"><span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n</td>\n</tr>\n<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="2">\n2\n</td>\n<td class="line_content new noteable_line"><span id="LC2" class="line" lang="plaintext"></span>\n</td>\n</tr>\n', - image_diff_html: - '<div class="image js-replaced-image" data="">\n<div class="two-up view">\n<div class="wrap">\n<div class="frame deleted">\n<img alt="CHANGELOG" src="http://localhost:3000/gitlab-org/gitlab-test/raw/e63f41fe459e62e1228fcef60d7189127aeba95a/CHANGELOG" />\n</div>\n<p class="image-info hide">\n<span class="meta-filesize">22.3 KB</span>\n|\n<strong>W:</strong>\n<span class="meta-width"></span>\n|\n<strong>H:</strong>\n<span class="meta-height"></span>\n</p>\n</div>\n<div class="wrap">\n<div class="added frame js-image-frame" data-note-type="DiffNote" data-position="{"base_sha":"e63f41fe459e62e1228fcef60d7189127aeba95a","start_sha":"d9eaefe5a676b820c57ff18cf5b68316025f7962","head_sha":"c48ee0d1bf3b30453f5b32250ce03134beaa6d13","old_path":"CHANGELOG","new_path":"CHANGELOG","position_type":"text","old_line":null,"new_line":2}">\n<img alt="CHANGELOG" draggable="false" src="http://localhost:3000/gitlab-org/gitlab-test/raw/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG" />\n</div>\n\n<p class="image-info hide">\n<span class="meta-filesize">22.3 KB</span>\n|\n<strong>W:</strong>\n<span class="meta-width"></span>\n|\n<strong>H:</strong>\n<span class="meta-height"></span>\n</p>\n</div>\n</div>\n<div class="swipe view hide">\n<div class="swipe-frame">\n<div class="frame deleted">\n<img alt="CHANGELOG" src="http://localhost:3000/gitlab-org/gitlab-test/raw/e63f41fe459e62e1228fcef60d7189127aeba95a/CHANGELOG" />\n</div>\n<div class="swipe-wrap">\n<div class="added frame js-image-frame" data-note-type="DiffNote" data-position="{"base_sha":"e63f41fe459e62e1228fcef60d7189127aeba95a","start_sha":"d9eaefe5a676b820c57ff18cf5b68316025f7962","head_sha":"c48ee0d1bf3b30453f5b32250ce03134beaa6d13","old_path":"CHANGELOG","new_path":"CHANGELOG","position_type":"text","old_line":null,"new_line":2}">\n<img alt="CHANGELOG" draggable="false" src="http://localhost:3000/gitlab-org/gitlab-test/raw/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG" />\n</div>\n\n</div>\n<span class="swipe-bar">\n<span class="top-handle"></span>\n<span class="bottom-handle"></span>\n</span>\n</div>\n</div>\n<div class="onion-skin view hide">\n<div class="onion-skin-frame">\n<div class="frame deleted">\n<img alt="CHANGELOG" src="http://localhost:3000/gitlab-org/gitlab-test/raw/e63f41fe459e62e1228fcef60d7189127aeba95a/CHANGELOG" />\n</div>\n<div class="added frame js-image-frame" data-note-type="DiffNote" data-position="{"base_sha":"e63f41fe459e62e1228fcef60d7189127aeba95a","start_sha":"d9eaefe5a676b820c57ff18cf5b68316025f7962","head_sha":"c48ee0d1bf3b30453f5b32250ce03134beaa6d13","old_path":"CHANGELOG","new_path":"CHANGELOG","position_type":"text","old_line":null,"new_line":2}">\n<img alt="CHANGELOG" draggable="false" src="http://localhost:3000/gitlab-org/gitlab-test/raw/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG" />\n</div>\n\n<div class="controls">\n<div class="transparent"></div>\n<div class="drag-track">\n<div class="dragger" style="left: 0px;"></div>\n</div>\n<div class="opaque"></div>\n</div>\n</div>\n</div>\n</div>\n<div class="view-modes hide">\n<ul class="view-modes-menu">\n<li class="two-up" data-mode="two-up">2-up</li>\n<li class="swipe" data-mode="swipe">Swipe</li>\n<li class="onion-skin" data-mode="onion-skin">Onion skin</li>\n</ul>\n</div>\n', }; export const imageDiffDiscussions = [ diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js index acd95a3dd8b..55ce19927e0 100644 --- a/spec/javascripts/diffs/store/actions_spec.js +++ b/spec/javascripts/diffs/store/actions_spec.js @@ -22,12 +22,14 @@ import actions, { expandAllFiles, toggleFileDiscussions, saveDiffDiscussion, + setHighlightedRow, toggleTreeOpen, scrollToFile, toggleShowTreeList, } from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import axios from '~/lib/utils/axios_utils'; +import mockDiffFile from 'spec/diffs/mock_data/diff_file'; import testAction from '../../helpers/vuex_action_helper'; describe('DiffsStoreActions', () => { @@ -92,6 +94,14 @@ describe('DiffsStoreActions', () => { }); }); + describe('setHighlightedRow', () => { + it('should set lineHash and fileHash of highlightedRow', () => { + testAction(setHighlightedRow, 'ABC_123', {}, [ + { type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' }, + ]); + }); + }); + describe('assignDiscussionsToDiff', () => { it('should merge discussions into diffs', done => { const state = { @@ -310,13 +320,13 @@ describe('DiffsStoreActions', () => { describe('showCommentForm', () => { it('should call mutation to show comment form', done => { - const payload = { lineCode: 'lineCode' }; + const payload = { lineCode: 'lineCode', fileHash: 'hash' }; testAction( showCommentForm, payload, {}, - [{ type: types.ADD_COMMENT_FORM_LINE, payload }], + [{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: true } }], [], done, ); @@ -325,13 +335,13 @@ describe('DiffsStoreActions', () => { describe('cancelCommentForm', () => { it('should call mutation to cancel comment form', done => { - const payload = { lineCode: 'lineCode' }; + const payload = { lineCode: 'lineCode', fileHash: 'hash' }; testAction( cancelCommentForm, payload, {}, - [{ type: types.REMOVE_COMMENT_FORM_LINE, payload }], + [{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: false } }], [], done, ); @@ -370,27 +380,50 @@ describe('DiffsStoreActions', () => { describe('loadCollapsedDiff', () => { it('should fetch data and call mutation with response and the give parameter', done => { - const file = { hash: 123, loadCollapsedDiffUrl: '/load/collapsed/diff/url' }; + const file = { hash: 123, load_collapsed_diff_url: '/load/collapsed/diff/url' }; const data = { hash: 123, parallelDiffLines: [{ lineCode: 1 }] }; const mock = new MockAdapter(axios); + const commit = jasmine.createSpy('commit'); mock.onGet(file.loadCollapsedDiffUrl).reply(200, data); - testAction( - loadCollapsedDiff, - file, - {}, - [ - { - type: types.ADD_COLLAPSED_DIFFS, - payload: { file, data }, - }, - ], - [], - () => { + loadCollapsedDiff({ commit, getters: { commitId: null } }, file) + .then(() => { + expect(commit).toHaveBeenCalledWith(types.ADD_COLLAPSED_DIFFS, { file, data }); + mock.restore(); done(); - }, - ); + }) + .catch(done.fail); + }); + + it('should fetch data without commit ID', () => { + const file = { load_collapsed_diff_url: '/load/collapsed/diff/url' }; + const getters = { + commitId: null, + }; + + spyOn(axios, 'get').and.returnValue(Promise.resolve({ data: {} })); + + loadCollapsedDiff({ commit() {}, getters }, file); + + expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { + params: { commit_id: null }, + }); + }); + + it('should fetch data with commit ID', () => { + const file = { load_collapsed_diff_url: '/load/collapsed/diff/url' }; + const getters = { + commitId: '123', + }; + + spyOn(axios, 'get').and.returnValue(Promise.resolve({ data: {} })); + + loadCollapsedDiff({ commit() {}, getters }, file); + + expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { + params: { commit_id: '123' }, + }); }); }); @@ -469,7 +502,7 @@ describe('DiffsStoreActions', () => { describe('scrollToLineIfNeededInline', () => { const lineMock = { - lineCode: 'ABC_123', + line_code: 'ABC_123', }; it('should not call handleLocationHash when there is not hash', () => { @@ -520,7 +553,7 @@ describe('DiffsStoreActions', () => { const lineMock = { left: null, right: { - lineCode: 'ABC_123', + line_code: 'ABC_123', }, }; @@ -575,11 +608,18 @@ describe('DiffsStoreActions', () => { }); describe('saveDiffDiscussion', () => { - beforeEach(() => { - spyOnDependency(actions, 'getNoteFormData').and.returnValue('testData'); - }); - it('dispatches actions', done => { + const commitId = 'something'; + const formData = { + diffFile: { ...mockDiffFile }, + noteableData: {}, + }; + const note = {}; + const state = { + commit: { + id: commitId, + }, + }; const dispatch = jasmine.createSpy('dispatch').and.callFake(name => { switch (name) { case 'saveNote': @@ -593,11 +633,19 @@ describe('DiffsStoreActions', () => { } }); - saveDiffDiscussion({ dispatch }, { note: {}, formData: {} }) + saveDiffDiscussion({ state, dispatch }, { note, formData }) .then(() => { - expect(dispatch.calls.argsFor(0)).toEqual(['saveNote', 'testData', { root: true }]); - expect(dispatch.calls.argsFor(1)).toEqual(['updateDiscussion', 'test', { root: true }]); - expect(dispatch.calls.argsFor(2)).toEqual(['assignDiscussionsToDiff', ['discussion']]); + const { calls } = dispatch; + + expect(calls.count()).toBe(5); + expect(calls.argsFor(0)).toEqual(['saveNote', jasmine.any(Object), { root: true }]); + + const postData = calls.argsFor(0)[1]; + + expect(postData.data.note.commit_id).toBe(commitId); + + expect(calls.argsFor(1)).toEqual(['updateDiscussion', 'test', { root: true }]); + expect(calls.argsFor(2)).toEqual(['assignDiscussionsToDiff', ['discussion']]); }) .then(done) .catch(done.fail); diff --git a/spec/javascripts/diffs/store/getters_spec.js b/spec/javascripts/diffs/store/getters_spec.js index eef95c823fb..582535e0a53 100644 --- a/spec/javascripts/diffs/store/getters_spec.js +++ b/spec/javascripts/diffs/store/getters_spec.js @@ -186,77 +186,6 @@ describe('Diffs Module Getters', () => { }); }); - describe('shouldRenderParallelCommentRow', () => { - let line; - - beforeEach(() => { - line = {}; - - discussionMock.expanded = true; - - line.left = { - line_code: 'ABC', - discussions: [discussionMock], - }; - - line.right = { - line_code: 'DEF', - discussions: [discussionMock1], - }; - }); - - it('returns true when discussion is expanded', () => { - expect(getters.shouldRenderParallelCommentRow(localState)(line)).toEqual(true); - }); - - it('returns false when no discussion was found', () => { - line.left.discussions = []; - line.right.discussions = []; - - localState.diffLineCommentForms.ABC = false; - localState.diffLineCommentForms.DEF = false; - - expect(getters.shouldRenderParallelCommentRow(localState)(line)).toEqual(false); - }); - - it('returns true when discussionForm was found', () => { - localState.diffLineCommentForms.ABC = {}; - - expect(getters.shouldRenderParallelCommentRow(localState)(line)).toEqual(true); - }); - }); - - describe('shouldRenderInlineCommentRow', () => { - let line; - - beforeEach(() => { - discussionMock.expanded = true; - - line = { - lineCode: 'ABC', - discussions: [discussionMock], - }; - }); - - it('returns true when diffLineCommentForms has form', () => { - localState.diffLineCommentForms.ABC = {}; - - expect(getters.shouldRenderInlineCommentRow(localState)(line)).toEqual(true); - }); - - it('returns false when no line discussions were found', () => { - line.discussions = []; - - expect(getters.shouldRenderInlineCommentRow(localState)(line)).toEqual(false); - }); - - it('returns true if all found discussions are expanded', () => { - discussionMock.expanded = true; - - expect(getters.shouldRenderInlineCommentRow(localState)(line)).toEqual(true); - }); - }); - describe('getDiffFileDiscussions', () => { it('returns an array with discussions when fileHash matches and the discussion belongs to a diff', () => { discussionMock.diff_file.file_hash = diffFileMock.file_hash; diff --git a/spec/javascripts/diffs/store/mutations_spec.js b/spec/javascripts/diffs/store/mutations_spec.js index 598d723c940..23e8761bc55 100644 --- a/spec/javascripts/diffs/store/mutations_spec.js +++ b/spec/javascripts/diffs/store/mutations_spec.js @@ -55,32 +55,6 @@ describe('DiffsStoreMutations', () => { }); }); - describe('ADD_COMMENT_FORM_LINE', () => { - it('should set a truthy reference for the given line code in diffLineCommentForms', () => { - const state = { diffLineCommentForms: {} }; - const lineCode = 'FDE'; - - mutations[types.ADD_COMMENT_FORM_LINE](state, { lineCode }); - - expect(state.diffLineCommentForms[lineCode]).toBeTruthy(); - }); - }); - - describe('REMOVE_COMMENT_FORM_LINE', () => { - it('should remove given reference from diffLineCommentForms', () => { - const state = { diffLineCommentForms: {} }; - const lineCode = 'FDE'; - - mutations[types.ADD_COMMENT_FORM_LINE](state, { lineCode }); - - expect(state.diffLineCommentForms[lineCode]).toBeTruthy(); - - mutations[types.REMOVE_COMMENT_FORM_LINE](state, { lineCode }); - - expect(state.diffLineCommentForms[lineCode]).toBeUndefined(); - }); - }); - describe('EXPAND_ALL_FILES', () => { it('should change the collapsed prop from diffFiles', () => { const diffFile = { @@ -98,7 +72,9 @@ describe('DiffsStoreMutations', () => { it('should call utils.addContextLines with proper params', () => { const options = { lineNumbers: { oldLineNumber: 1, newLineNumber: 2 }, - contextLines: [{ old_line: 1, new_line: 1, line_code: 'ff9200_1_1', discussions: [] }], + contextLines: [ + { old_line: 1, new_line: 1, line_code: 'ff9200_1_1', discussions: [], hasForm: false }, + ], fileHash: 'ff9200', params: { bottom: true, @@ -223,6 +199,84 @@ describe('DiffsStoreMutations', () => { expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1); }); + it('should not duplicate discussions on line', () => { + const diffPosition = { + base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910', + head_sha: 'b921914f9a834ac47e6fd9420f78db0f83559130', + new_line: null, + new_path: '500-lines-4.txt', + old_line: 5, + old_path: '500-lines-4.txt', + start_sha: 'ed13df29948c41ba367caa757ab3ec4892509910', + }; + + const state = { + latestDiff: true, + diffFiles: [ + { + file_hash: 'ABC', + parallel_diff_lines: [ + { + left: { + line_code: 'ABC_1', + discussions: [], + }, + right: { + line_code: 'ABC_1', + discussions: [], + }, + }, + ], + highlighted_diff_lines: [ + { + line_code: 'ABC_1', + discussions: [], + }, + ], + }, + ], + }; + const discussion = { + id: 1, + line_code: 'ABC_1', + diff_discussion: true, + resolvable: true, + original_position: diffPosition, + position: diffPosition, + diff_file: { + file_hash: state.diffFiles[0].file_hash, + }, + }; + + const diffPositionByLineCode = { + ABC_1: diffPosition, + }; + + mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { + discussion, + diffPositionByLineCode, + }); + + expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1); + expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1); + expect(state.diffFiles[0].parallel_diff_lines[0].right.discussions).toEqual([]); + + expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1); + expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1); + + mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { + discussion, + diffPositionByLineCode, + }); + + expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1); + expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1); + expect(state.diffFiles[0].parallel_diff_lines[0].right.discussions).toEqual([]); + + expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1); + expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1); + }); + it('should add legacy discussions to the given line', () => { const diffPosition = { base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910', @@ -383,4 +437,45 @@ describe('DiffsStoreMutations', () => { expect(state.currentDiffFileId).toBe('somefileid'); }); }); + + describe('Set highlighted row', () => { + it('sets highlighted row', () => { + const state = createState(); + + mutations[types.SET_HIGHLIGHTED_ROW](state, 'ABC_123'); + + expect(state.highlightedRow).toBe('ABC_123'); + }); + }); + + describe('TOGGLE_LINE_HAS_FORM', () => { + it('sets hasForm on lines', () => { + const file = { + file_hash: 'hash', + parallel_diff_lines: [ + { left: { line_code: '123', hasForm: false }, right: {} }, + { left: {}, right: { line_code: '124', hasForm: false } }, + ], + highlighted_diff_lines: [ + { line_code: '123', hasForm: false }, + { line_code: '124', hasForm: false }, + ], + }; + const state = { + diffFiles: [file], + }; + + mutations[types.TOGGLE_LINE_HAS_FORM](state, { + lineCode: '123', + hasForm: true, + fileHash: 'hash', + }); + + expect(file.highlighted_diff_lines[0].hasForm).toBe(true); + expect(file.highlighted_diff_lines[1].hasForm).toBe(false); + + expect(file.parallel_diff_lines[0].left.hasForm).toBe(true); + expect(file.parallel_diff_lines[1].right.hasForm).toBe(false); + }); + }); }); diff --git a/spec/javascripts/diffs/store/utils_spec.js b/spec/javascripts/diffs/store/utils_spec.js index d4ef17c5ef8..f096638e3d6 100644 --- a/spec/javascripts/diffs/store/utils_spec.js +++ b/spec/javascripts/diffs/store/utils_spec.js @@ -150,7 +150,7 @@ describe('DiffsStoreUtils', () => { note: { noteable_type: options.noteableType, noteable_id: options.noteableData.id, - commit_id: '', + commit_id: undefined, type: DIFF_NOTE_TYPE, line_code: options.noteTargetLine.line_code, note: options.note, @@ -209,7 +209,7 @@ describe('DiffsStoreUtils', () => { note: { noteable_type: options.noteableType, noteable_id: options.noteableData.id, - commit_id: '', + commit_id: undefined, type: LEGACY_DIFF_NOTE_TYPE, line_code: options.noteTargetLine.line_code, note: options.note, @@ -559,4 +559,26 @@ describe('DiffsStoreUtils', () => { ]); }); }); + + describe('getDiffMode', () => { + it('returns mode when matched in file', () => { + expect( + utils.getDiffMode({ + renamed_file: true, + }), + ).toBe('renamed'); + }); + + it('returns mode_changed if key has no match', () => { + expect( + utils.getDiffMode({ + mode_changed: true, + }), + ).toBe('mode_changed'); + }); + + it('defaults to replaced', () => { + expect(utils.getDiffMode({})).toBe('replaced'); + }); + }); }); diff --git a/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js b/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js index 04925d37ac4..9e2ba1f5ce9 100644 --- a/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js +++ b/spec/javascripts/ide/stores/modules/merge_requests/actions_spec.js @@ -14,10 +14,14 @@ import testAction from '../../../../helpers/vuex_action_helper'; describe('IDE merge requests actions', () => { let mockedState; + let mockedRootState; let mock; beforeEach(() => { mockedState = state(); + mockedRootState = { + currentProjectId: 7, + }; mock = new MockAdapter(axios); }); @@ -86,13 +90,16 @@ describe('IDE merge requests actions', () => { describe('success', () => { beforeEach(() => { - mock.onGet(/\/api\/v4\/merge_requests(.*)$/).replyOnce(200, mergeRequests); + mock.onGet(/\/api\/v4\/merge_requests\/?/).replyOnce(200, mergeRequests); }); it('calls API with params', () => { const apiSpy = spyOn(axios, 'get').and.callThrough(); - fetchMergeRequests({ dispatch() {}, state: mockedState }, { type: 'created' }); + fetchMergeRequests( + { dispatch() {}, state: mockedState, rootState: mockedRootState }, + { type: 'created' }, + ); expect(apiSpy).toHaveBeenCalledWith(jasmine.anything(), { params: { @@ -107,7 +114,7 @@ describe('IDE merge requests actions', () => { const apiSpy = spyOn(axios, 'get').and.callThrough(); fetchMergeRequests( - { dispatch() {}, state: mockedState }, + { dispatch() {}, state: mockedState, rootState: mockedRootState }, { type: 'created', search: 'testing search' }, ); @@ -139,6 +146,49 @@ describe('IDE merge requests actions', () => { }); }); + describe('success without type', () => { + beforeEach(() => { + mock.onGet(/\/api\/v4\/projects\/.+\/merge_requests\/?$/).replyOnce(200, mergeRequests); + }); + + it('calls API with project', () => { + const apiSpy = spyOn(axios, 'get').and.callThrough(); + + fetchMergeRequests( + { dispatch() {}, state: mockedState, rootState: mockedRootState }, + { type: null, search: 'testing search' }, + ); + + expect(apiSpy).toHaveBeenCalledWith( + jasmine.stringMatching(`projects/${mockedRootState.currentProjectId}/merge_requests`), + { + params: { + state: 'opened', + search: 'testing search', + }, + }, + ); + }); + + it('dispatches success with received data', done => { + testAction( + fetchMergeRequests, + { type: null }, + { ...mockedState, ...mockedRootState }, + [], + [ + { type: 'requestMergeRequests' }, + { type: 'resetMergeRequests' }, + { + type: 'receiveMergeRequestsSuccess', + payload: mergeRequests, + }, + ], + done, + ); + }); + }); + describe('error', () => { beforeEach(() => { mock.onGet(/\/api\/v4\/merge_requests(.*)$/).replyOnce(500); diff --git a/spec/javascripts/jobs/components/job_app_spec.js b/spec/javascripts/jobs/components/job_app_spec.js index fcf3780f0ea..ba5d672f189 100644 --- a/spec/javascripts/jobs/components/job_app_spec.js +++ b/spec/javascripts/jobs/components/job_app_spec.js @@ -160,9 +160,7 @@ describe('Job App ', () => { setTimeout(() => { expect(vm.$el.querySelector('.js-job-stuck')).not.toBeNull(); - expect(vm.$el.querySelector('.js-job-stuck').textContent).toContain( - "This job is stuck, because you don't have any active runners that can run this job.", - ); + expect(vm.$el.querySelector('.js-job-stuck .js-stuck-no-active-runner')).not.toBeNull(); done(); }, 0); }); @@ -195,9 +193,7 @@ describe('Job App ', () => { setTimeout(() => { expect(vm.$el.querySelector('.js-job-stuck').textContent).toContain(job.tags[0]); - expect(vm.$el.querySelector('.js-job-stuck').textContent).toContain( - "This job is stuck, because you don't have any active runners online with any of these tags assigned to them:", - ); + expect(vm.$el.querySelector('.js-job-stuck .js-stuck-with-tags')).not.toBeNull(); done(); }, 0); }); @@ -230,9 +226,7 @@ describe('Job App ', () => { setTimeout(() => { expect(vm.$el.querySelector('.js-job-stuck').textContent).toContain(job.tags[0]); - expect(vm.$el.querySelector('.js-job-stuck').textContent).toContain( - "This job is stuck, because you don't have any active runners online with any of these tags assigned to them:", - ); + expect(vm.$el.querySelector('.js-job-stuck .js-stuck-with-tags')).not.toBeNull(); done(); }, 0); }); diff --git a/spec/javascripts/lib/utils/dom_utils_spec.js b/spec/javascripts/lib/utils/dom_utils_spec.js index 1fb2e4584a0..2bcf37f35c7 100644 --- a/spec/javascripts/lib/utils/dom_utils_spec.js +++ b/spec/javascripts/lib/utils/dom_utils_spec.js @@ -1,4 +1,6 @@ -import { addClassIfElementExists } from '~/lib/utils/dom_utils'; +import { addClassIfElementExists, canScrollUp, canScrollDown } from '~/lib/utils/dom_utils'; + +const TEST_MARGIN = 5; describe('DOM Utils', () => { describe('addClassIfElementExists', () => { @@ -34,4 +36,54 @@ describe('DOM Utils', () => { addClassIfElementExists(childElement, className); }); }); + + describe('canScrollUp', () => { + [1, 100].forEach(scrollTop => { + it(`is true if scrollTop is > 0 (${scrollTop})`, () => { + expect(canScrollUp({ scrollTop })).toBe(true); + }); + }); + + [0, -10].forEach(scrollTop => { + it(`is false if scrollTop is <= 0 (${scrollTop})`, () => { + expect(canScrollUp({ scrollTop })).toBe(false); + }); + }); + + it('is true if scrollTop is > margin', () => { + expect(canScrollUp({ scrollTop: TEST_MARGIN + 1 }, TEST_MARGIN)).toBe(true); + }); + + it('is false if scrollTop is <= margin', () => { + expect(canScrollUp({ scrollTop: TEST_MARGIN }, TEST_MARGIN)).toBe(false); + }); + }); + + describe('canScrollDown', () => { + let element; + + beforeEach(() => { + element = { scrollTop: 7, offsetHeight: 22, scrollHeight: 30 }; + }); + + it('is true if element can be scrolled down', () => { + expect(canScrollDown(element)).toBe(true); + }); + + it('is false if element cannot be scrolled down', () => { + element.scrollHeight -= 1; + + expect(canScrollDown(element)).toBe(false); + }); + + it('is true if element can be scrolled down, with margin given', () => { + element.scrollHeight += TEST_MARGIN; + + expect(canScrollDown(element, TEST_MARGIN)).toBe(true); + }); + + it('is false if element cannot be scrolled down, with margin given', () => { + expect(canScrollDown(element, TEST_MARGIN)).toBe(false); + }); + }); }); diff --git a/spec/javascripts/lib/utils/file_upload_spec.js b/spec/javascripts/lib/utils/file_upload_spec.js new file mode 100644 index 00000000000..92c9cc70aaf --- /dev/null +++ b/spec/javascripts/lib/utils/file_upload_spec.js @@ -0,0 +1,36 @@ +import fileUpload from '~/lib/utils/file_upload'; + +describe('File upload', () => { + beforeEach(() => { + setFixtures(` + <form> + <button class="js-button" type="button">Click me!</button> + <input type="text" class="js-input" /> + <span class="js-filename"></span> + </form> + `); + + fileUpload('.js-button', '.js-input'); + }); + + it('clicks file input after clicking button', () => { + const btn = document.querySelector('.js-button'); + const input = document.querySelector('.js-input'); + + spyOn(input, 'click'); + + btn.click(); + + expect(input.click).toHaveBeenCalled(); + }); + + it('updates file name text', () => { + const input = document.querySelector('.js-input'); + + input.value = 'path/to/file/index.js'; + + input.dispatchEvent(new CustomEvent('change')); + + expect(document.querySelector('.js-filename').textContent).toEqual('index.js'); + }); +}); diff --git a/spec/javascripts/lib/utils/url_utility_spec.js b/spec/javascripts/lib/utils/url_utility_spec.js index c7f4092911c..e4df8441793 100644 --- a/spec/javascripts/lib/utils/url_utility_spec.js +++ b/spec/javascripts/lib/utils/url_utility_spec.js @@ -1,4 +1,4 @@ -import { webIDEUrl } from '~/lib/utils/url_utility'; +import { webIDEUrl, mergeUrlParams } from '~/lib/utils/url_utility'; describe('URL utility', () => { describe('webIDEUrl', () => { @@ -26,4 +26,26 @@ describe('URL utility', () => { }); }); }); + + describe('mergeUrlParams', () => { + it('adds w', () => { + expect(mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag'); + expect(mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag'); + expect(mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1'); + expect(mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe('https://host/path?w=1#frag'); + expect(mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe('https://h/p?k1=v1&w=1#frag'); + }); + + it('updates w', () => { + expect(mergeUrlParams({ w: 1 }, '?k1=v1&w=0#frag')).toBe('?k1=v1&w=1#frag'); + }); + + it('adds multiple params', () => { + expect(mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag'); + }); + + it('adds and updates encoded params', () => { + expect(mergeUrlParams({ a: '&', q: '?' }, '?a=%23#frag')).toBe('?a=%26&q=%3F#frag'); + }); + }); }); diff --git a/spec/javascripts/monitoring/graph_spec.js b/spec/javascripts/monitoring/graph_spec.js index 4cc18afdf24..59d6d4f3a7f 100644 --- a/spec/javascripts/monitoring/graph_spec.js +++ b/spec/javascripts/monitoring/graph_spec.js @@ -5,6 +5,7 @@ import { deploymentData, convertDatesMultipleSeries, singleRowMetricsMultipleSeries, + queryWithoutData, } from './mock_data'; const tagsPath = 'http://test.host/frontend-fixtures/environments-project/tags'; @@ -104,4 +105,23 @@ describe('Graph', () => { expect(component.currentData).toBe(component.timeSeries[0].values[10]); }); + + describe('Without data to display', () => { + it('shows a "no data to display" empty state on a graph', done => { + const component = createComponent({ + graphData: queryWithoutData, + deploymentData, + tagsPath, + projectPath, + }); + + Vue.nextTick(() => { + expect( + component.$el.querySelector('.js-no-data-to-display text').textContent.trim(), + ).toEqual('No data to display'); + + done(); + }); + }); + }); }); diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js index 6c833b17f98..18ad9843d22 100644 --- a/spec/javascripts/monitoring/mock_data.js +++ b/spec/javascripts/monitoring/mock_data.js @@ -14,7 +14,7 @@ export const metricsGroupsAPIResponse = { queries: [ { query_range: 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20', - y_label: 'Memory', + label: 'Memory', unit: 'MiB', result: [ { @@ -324,12 +324,15 @@ export const metricsGroupsAPIResponse = { ], }, { + id: 6, title: 'CPU usage', weight: 1, queries: [ { query_range: 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100', + label: 'Core Usage', + unit: 'Cores', result: [ { metric: {}, @@ -639,6 +642,39 @@ export const metricsGroupsAPIResponse = { }, ], }, + { + group: 'NGINX', + priority: 2, + metrics: [ + { + id: 100, + title: 'Http Error Rate', + weight: 100, + queries: [ + { + query_range: + 'sum(rate(nginx_upstream_responses_total{status_code="5xx", upstream=~"nginx-test-8691397-production-.*"}[2m])) / sum(rate(nginx_upstream_responses_total{upstream=~"nginx-test-8691397-production-.*"}[2m])) * 100', + label: '5xx errors', + unit: '%', + result: [ + { + metric: {}, + values: [ + [1495700554.925, NaN], + [1495700614.925, NaN], + [1495700674.925, NaN], + [1495700734.925, NaN], + [1495700794.925, NaN], + [1495700854.925, NaN], + [1495700914.925, NaN], + ], + }, + ], + }, + ], + }, + ], + }, ], last_update: '2017-05-25T13:18:34.949Z', }; @@ -6526,6 +6562,21 @@ export const singleRowMetricsMultipleSeries = [ }, ]; +export const queryWithoutData = { + title: 'HTTP Error rate', + weight: 10, + y_label: 'Http Error Rate', + queries: [ + { + query_range: + 'sum(rate(nginx_upstream_responses_total{status_code="5xx", upstream=~"nginx-test-8691397-production-.*"}[2m])) / sum(rate(nginx_upstream_responses_total{upstream=~"nginx-test-8691397-production-.*"}[2m])) * 100', + label: '5xx errors', + unit: '%', + result: [], + }, + ], +}; + export function convertDatesMultipleSeries(multipleSeries) { const convertedMultiple = multipleSeries; multipleSeries.forEach((column, index) => { diff --git a/spec/javascripts/monitoring/monitoring_store_spec.js b/spec/javascripts/monitoring/monitoring_store_spec.js index bf68c911549..d8a980c874d 100644 --- a/spec/javascripts/monitoring/monitoring_store_spec.js +++ b/spec/javascripts/monitoring/monitoring_store_spec.js @@ -1,31 +1,35 @@ import MonitoringStore from '~/monitoring/stores/monitoring_store'; import MonitoringMock, { deploymentData, environmentData } from './mock_data'; -describe('MonitoringStore', function() { - this.store = new MonitoringStore(); - this.store.storeMetrics(MonitoringMock.data); - - it('contains one group that contains two queries sorted by priority', () => { - expect(this.store.groups).toBeDefined(); - expect(this.store.groups.length).toEqual(1); - expect(this.store.groups[0].metrics.length).toEqual(2); +describe('MonitoringStore', () => { + const store = new MonitoringStore(); + store.storeMetrics(MonitoringMock.data); + + it('contains two groups that contains, one of which has two queries sorted by priority', () => { + expect(store.groups).toBeDefined(); + expect(store.groups.length).toEqual(2); + expect(store.groups[0].metrics.length).toEqual(2); }); it('gets the metrics count for every group', () => { - expect(this.store.getMetricsCount()).toEqual(2); + expect(store.getMetricsCount()).toEqual(3); }); it('contains deployment data', () => { - this.store.storeDeploymentData(deploymentData); + store.storeDeploymentData(deploymentData); - expect(this.store.deploymentData).toBeDefined(); - expect(this.store.deploymentData.length).toEqual(3); - expect(typeof this.store.deploymentData[0]).toEqual('object'); + expect(store.deploymentData).toBeDefined(); + expect(store.deploymentData.length).toEqual(3); + expect(typeof store.deploymentData[0]).toEqual('object'); }); it('only stores environment data that contains deployments', () => { - this.store.storeEnvironmentsData(environmentData); + store.storeEnvironmentsData(environmentData); + + expect(store.environmentsData.length).toEqual(2); + }); - expect(this.store.environmentsData.length).toEqual(2); + it('removes the data if all the values from a query are not defined', () => { + expect(store.groups[1].metrics[0].queries[0].result.length).toEqual(0); }); }); diff --git a/spec/javascripts/notes/components/diff_with_note_spec.js b/spec/javascripts/notes/components/diff_with_note_spec.js index 95461396f10..0752bd05904 100644 --- a/spec/javascripts/notes/components/diff_with_note_spec.js +++ b/spec/javascripts/notes/components/diff_with_note_spec.js @@ -17,7 +17,7 @@ describe('diff_with_note', () => { }; const selectors = { get container() { - return vm.$refs.fileHolder; + return vm.$el; }, get diffTable() { return this.container.querySelector('.diff-content table'); @@ -70,7 +70,6 @@ describe('diff_with_note', () => { it('shows image diff', () => { vm = mountComponentWithStore(Component, { props, store }); - expect(selectors.container).toHaveClass('js-image-file'); expect(selectors.diffTable).not.toExist(); }); }); diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js index 4b4403689d9..ab9c52346d6 100644 --- a/spec/javascripts/notes/components/noteable_discussion_spec.js +++ b/spec/javascripts/notes/components/noteable_discussion_spec.js @@ -6,7 +6,6 @@ import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data'; import mockDiffFile from '../../diffs/mock_data/diff_file'; const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; -const diffDiscussionFixture = 'merge_requests/diff_discussion.json'; describe('noteable_discussion component', () => { const Component = Vue.extend(noteableDiscussion); @@ -79,88 +78,6 @@ describe('noteable_discussion component', () => { }); }); - describe('computed', () => { - describe('hasMultipleUnresolvedDiscussions', () => { - it('is false if there are no unresolved discussions', done => { - spyOnProperty(vm, 'unresolvedDiscussions').and.returnValue([]); - - Vue.nextTick() - .then(() => { - expect(vm.hasMultipleUnresolvedDiscussions).toBe(false); - }) - .then(done) - .catch(done.fail); - }); - - it('is false if there is one unresolved discussion', done => { - spyOnProperty(vm, 'unresolvedDiscussions').and.returnValue([discussionMock]); - - Vue.nextTick() - .then(() => { - expect(vm.hasMultipleUnresolvedDiscussions).toBe(false); - }) - .then(done) - .catch(done.fail); - }); - - it('is true if there are two unresolved discussions', done => { - const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0]; - discussion.notes[0].resolved = false; - vm.$store.dispatch('setInitialNotes', [discussion, discussion]); - - Vue.nextTick() - .then(() => { - expect(vm.hasMultipleUnresolvedDiscussions).toBe(true); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('isRepliesCollapsed', () => { - it('should return false for diff discussions', done => { - const diffDiscussion = getJSONFixture(diffDiscussionFixture)[0]; - vm.$store.dispatch('setInitialNotes', [diffDiscussion]); - - Vue.nextTick() - .then(() => { - expect(vm.isRepliesCollapsed).toEqual(false); - expect(vm.$el.querySelector('.js-toggle-replies')).not.toBeNull(); - expect(vm.$el.querySelector('.discussion-reply-holder')).not.toBeNull(); - }) - .then(done) - .catch(done.fail); - }); - - it('should return false if discussion does not have a reply', () => { - const discussion = { ...discussionMock, resolved: true }; - discussion.notes = discussion.notes.slice(0, 1); - const noRepliesVm = new Component({ - store, - propsData: { discussion }, - }).$mount(); - - expect(noRepliesVm.isRepliesCollapsed).toEqual(false); - expect(noRepliesVm.$el.querySelector('.js-toggle-replies')).toBeNull(); - expect(vm.$el.querySelector('.discussion-reply-holder')).not.toBeNull(); - noRepliesVm.$destroy(); - }); - - it('should return true for resolved non-diff discussion which has replies', () => { - const discussion = { ...discussionMock, resolved: true }; - const resolvedDiscussionVm = new Component({ - store, - propsData: { discussion }, - }).$mount(); - - expect(resolvedDiscussionVm.isRepliesCollapsed).toEqual(true); - expect(resolvedDiscussionVm.$el.querySelector('.js-toggle-replies')).not.toBeNull(); - expect(vm.$el.querySelector('.discussion-reply-holder')).not.toBeNull(); - resolvedDiscussionVm.$destroy(); - }); - }); - }); - describe('methods', () => { describe('jumpToNextDiscussion', () => { it('expands next unresolved discussion', done => { diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js index fcdd834e4a0..24c2b3e6570 100644 --- a/spec/javascripts/notes/stores/actions_spec.js +++ b/spec/javascripts/notes/stores/actions_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import $ from 'jquery'; import _ from 'underscore'; import { headersInterceptor } from 'spec/helpers/vue_resource_helper'; import * as actions from '~/notes/stores/actions'; @@ -330,10 +331,14 @@ describe('Actions Notes Store', () => { beforeEach(() => { Vue.http.interceptors.push(interceptor); + + $('body').attr('data-page', ''); }); afterEach(() => { Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + + $('body').attr('data-page', ''); }); it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', done => { @@ -353,6 +358,39 @@ describe('Actions Notes Store', () => { { type: 'updateMergeRequestWidget', }, + { + type: 'updateResolvableDiscussonsCounts', + }, + ], + done, + ); + }); + + it('dispatches removeDiscussionsFromDiff on merge request page', done => { + const note = { path: `${gl.TEST_HOST}`, id: 1 }; + + $('body').attr('data-page', 'projects:merge_requests:show'); + + testAction( + actions.deleteNote, + note, + store.state, + [ + { + type: 'DELETE_NOTE', + payload: note, + }, + ], + [ + { + type: 'updateMergeRequestWidget', + }, + { + type: 'updateResolvableDiscussonsCounts', + }, + { + type: 'diffs/removeDiscussionsFromDiff', + }, ], done, ); @@ -399,6 +437,9 @@ describe('Actions Notes Store', () => { { type: 'startTaskList', }, + { + type: 'updateResolvableDiscussonsCounts', + }, ], done, ); @@ -472,6 +513,9 @@ describe('Actions Notes Store', () => { ], [ { + type: 'updateResolvableDiscussonsCounts', + }, + { type: 'updateMergeRequestWidget', }, ], @@ -494,6 +538,9 @@ describe('Actions Notes Store', () => { ], [ { + type: 'updateResolvableDiscussonsCounts', + }, + { type: 'updateMergeRequestWidget', }, ], @@ -525,4 +572,17 @@ describe('Actions Notes Store', () => { ); }); }); + + describe('updateResolvableDiscussonsCounts', () => { + it('commits UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', done => { + testAction( + actions.updateResolvableDiscussonsCounts, + null, + {}, + [{ type: 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS' }], + [], + done, + ); + }); + }); }); diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js index f853f9ff088..c066975a43b 100644 --- a/spec/javascripts/notes/stores/getters_spec.js +++ b/spec/javascripts/notes/stores/getters_spec.js @@ -117,17 +117,15 @@ describe('Getters Notes Store', () => { describe('allResolvableDiscussions', () => { it('should return only resolvable discussions in same order', () => { - const localGetters = { - allDiscussions: [ - discussion3, - unresolvableDiscussion, - discussion1, - unresolvableDiscussion, - discussion2, - ], - }; + state.discussions = [ + discussion3, + unresolvableDiscussion, + discussion1, + unresolvableDiscussion, + discussion2, + ]; - expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([ + expect(getters.allResolvableDiscussions(state)).toEqual([ discussion3, discussion1, discussion2, @@ -135,11 +133,9 @@ describe('Getters Notes Store', () => { }); it('should return empty array if there are no resolvable discussions', () => { - const localGetters = { - allDiscussions: [unresolvableDiscussion, unresolvableDiscussion], - }; + state.discussions = [unresolvableDiscussion, unresolvableDiscussion]; - expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([]); + expect(getters.allResolvableDiscussions(state)).toEqual([]); }); }); @@ -236,7 +232,7 @@ describe('Getters Notes Store', () => { it('should return the ID of the discussion after the ID provided', () => { expect(getters.nextUnresolvedDiscussionId(state, localGetters)('123')).toBe('456'); expect(getters.nextUnresolvedDiscussionId(state, localGetters)('456')).toBe('789'); - expect(getters.nextUnresolvedDiscussionId(state, localGetters)('789')).toBe(undefined); + expect(getters.nextUnresolvedDiscussionId(state, localGetters)('789')).toBe('123'); }); }); diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js index 461de5a3106..52cdc16353a 100644 --- a/spec/javascripts/notes/stores/mutation_spec.js +++ b/spec/javascripts/notes/stores/mutation_spec.js @@ -297,6 +297,16 @@ describe('Notes Store mutations', () => { expect(state.discussions[0].expanded).toEqual(false); }); + + it('forces a discussions expanded state', () => { + const state = { + discussions: [{ ...discussionMock, expanded: false }], + }; + + mutations.TOGGLE_DISCUSSION(state, { discussionId: discussionMock.id, forceExpanded: true }); + + expect(state.discussions[0].expanded).toEqual(true); + }); }); describe('UPDATE_NOTE', () => { @@ -437,4 +447,51 @@ describe('Notes Store mutations', () => { expect(state.commentsDisabled).toEqual(true); }); }); + + describe('UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', () => { + it('updates resolvableDiscussionsCount', () => { + const state = { + discussions: [ + { individual_note: false, resolvable: true, notes: [] }, + { individual_note: true, resolvable: true, notes: [] }, + { individual_note: false, resolvable: false, notes: [] }, + ], + resolvableDiscussionsCount: 0, + }; + + mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state); + + expect(state.resolvableDiscussionsCount).toBe(1); + }); + + it('updates unresolvedDiscussionsCount', () => { + const state = { + discussions: [ + { individual_note: false, resolvable: true, notes: [{ resolved: false }] }, + { individual_note: true, resolvable: true, notes: [{ resolved: false }] }, + { individual_note: false, resolvable: false, notes: [{ resolved: false }] }, + ], + unresolvedDiscussionsCount: 0, + }; + + mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state); + + expect(state.unresolvedDiscussionsCount).toBe(1); + }); + + it('updates hasUnresolvedDiscussions', () => { + const state = { + discussions: [ + { individual_note: false, resolvable: true, notes: [{ resolved: false }] }, + { individual_note: false, resolvable: true, notes: [{ resolved: false }] }, + { individual_note: false, resolvable: false, notes: [{ resolved: false }] }, + ], + hasUnresolvedDiscussions: 0, + }; + + mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state); + + expect(state.hasUnresolvedDiscussions).toBe(true); + }); + }); }); diff --git a/spec/javascripts/pipelines/graph/job_item_spec.js b/spec/javascripts/pipelines/graph/job_item_spec.js index 88e1789184d..1cdb0aff524 100644 --- a/spec/javascripts/pipelines/graph/job_item_spec.js +++ b/spec/javascripts/pipelines/graph/job_item_spec.js @@ -139,57 +139,17 @@ describe('pipeline graph job item', () => { }); }); - describe('tooltip placement', () => { - it('does not set tooltip boundary by default', () => { - component = mountComponent(JobComponent, { - job: mockJob, - }); - - expect(component.tooltipBoundary).toBeNull(); - }); - - it('sets tooltip boundary to viewport for small dropdowns', () => { - component = mountComponent(JobComponent, { - job: mockJob, - dropdownLength: 1, - }); - - expect(component.tooltipBoundary).toEqual('viewport'); - }); - - it('does not set tooltip boundary for large lists', () => { - component = mountComponent(JobComponent, { - job: mockJob, - dropdownLength: 7, - }); - - expect(component.tooltipBoundary).toBeNull(); - }); - }); - describe('for delayed job', () => { - beforeEach(() => { - const fifteenMinutesInMilliseconds = 900000; - spyOn(Date, 'now').and.callFake( - () => new Date(delayedJobFixture.scheduled_at).getTime() - fifteenMinutesInMilliseconds, - ); - }); - - it('displays remaining time in tooltip', done => { + it('displays remaining time in tooltip', () => { component = mountComponent(JobComponent, { job: delayedJobFixture, }); - Vue.nextTick() - .then(() => { - expect( - component.$el - .querySelector('.js-pipeline-graph-job-link') - .getAttribute('data-original-title'), - ).toEqual('delayed job - delayed manual action (00:15:00)'); - }) - .then(done) - .catch(done.fail); + expect( + component.$el + .querySelector('.js-pipeline-graph-job-link') + .getAttribute('data-original-title'), + ).toEqual(`delayed job - delayed manual action (${component.remainingTime})`); }); }); }); diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js index d6c44f4c976..ea917b36526 100644 --- a/spec/javascripts/pipelines/pipeline_url_spec.js +++ b/spec/javascripts/pipelines/pipeline_url_spec.js @@ -90,7 +90,7 @@ describe('Pipeline Url Component', () => { expect(component.$el.querySelector('.js-pipeline-url-api').textContent).toContain('API'); }); - it('should render latest, yaml invalid and stuck flags when provided', () => { + it('should render latest, yaml invalid, merge request, and stuck flags when provided', () => { const component = new PipelineUrlComponent({ propsData: { pipeline: { @@ -100,6 +100,7 @@ describe('Pipeline Url Component', () => { latest: true, yaml_errors: true, stuck: true, + merge_request: true, }, }, autoDevopsHelpPath: 'foo', @@ -111,6 +112,10 @@ describe('Pipeline Url Component', () => { 'yaml invalid', ); + expect(component.$el.querySelector('.js-pipeline-url-mergerequest').textContent).toContain( + 'merge request', + ); + expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck'); }); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_container_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_container_spec.js new file mode 100644 index 00000000000..16c8c939a6f --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_container_spec.js @@ -0,0 +1,51 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import MrWidgetContainer from '~/vue_merge_request_widget/components/mr_widget_container.vue'; + +const BODY_HTML = '<div class="test-body">Hello World</div>'; +const FOOTER_HTML = '<div class="test-footer">Goodbye!</div>'; + +describe('MrWidgetContainer', () => { + let wrapper; + + const factory = (options = {}) => { + const localVue = createLocalVue(); + + wrapper = shallowMount(localVue.extend(MrWidgetContainer), { + localVue, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('has layout', () => { + factory(); + + expect(wrapper.is('.mr-widget-heading')).toBe(true); + expect(wrapper.contains('.mr-widget-content')).toBe(true); + }); + + it('accepts default slot', () => { + factory({ + slots: { + default: BODY_HTML, + }, + }); + + expect(wrapper.contains('.mr-widget-content .test-body')).toBe(true); + }); + + it('accepts footer slot', () => { + factory({ + slots: { + default: BODY_HTML, + footer: FOOTER_HTML, + }, + }); + + expect(wrapper.contains('.mr-widget-content .test-body')).toBe(true); + expect(wrapper.contains('.test-footer')).toBe(true); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_icon_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_icon_spec.js new file mode 100644 index 00000000000..f7c2376eebf --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_icon_spec.js @@ -0,0 +1,30 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +const TEST_ICON = 'commit'; + +describe('MrWidgetIcon', () => { + let wrapper; + + beforeEach(() => { + const localVue = createLocalVue(); + + wrapper = shallowMount(localVue.extend(MrWidgetIcon), { + propsData: { + name: TEST_ICON, + }, + sync: false, + localVue, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders icon and container', () => { + expect(wrapper.is('.circle-icon-container')).toBe(true); + expect(wrapper.find(Icon).props('name')).toEqual(TEST_ICON); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js new file mode 100644 index 00000000000..e5155573f6f --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js @@ -0,0 +1,90 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue'; +import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; +import { mockStore } from '../mock_data'; + +describe('MrWidgetPipelineContainer', () => { + let wrapper; + + const factory = (props = {}) => { + const localVue = createLocalVue(); + + wrapper = shallowMount(localVue.extend(MrWidgetPipelineContainer), { + propsData: { + mr: Object.assign({}, mockStore), + ...props, + }, + localVue, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when pre merge', () => { + beforeEach(() => { + factory(); + }); + + it('renders pipeline', () => { + expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(wrapper.find(MrWidgetPipeline).props()).toEqual( + jasmine.objectContaining({ + pipeline: mockStore.pipeline, + ciStatus: mockStore.ciStatus, + hasCi: mockStore.hasCI, + sourceBranch: mockStore.sourceBranch, + sourceBranchLink: mockStore.sourceBranchLink, + }), + ); + }); + + it('renders deployments', () => { + const expectedProps = mockStore.deployments.map(dep => + jasmine.objectContaining({ + deployment: dep, + showMetrics: false, + }), + ); + + const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment'); + + expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps); + }); + }); + + describe('when post merge', () => { + beforeEach(() => { + factory({ + isPostMerge: true, + }); + }); + + it('renders pipeline', () => { + expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(wrapper.find(MrWidgetPipeline).props()).toEqual( + jasmine.objectContaining({ + pipeline: mockStore.mergePipeline, + ciStatus: mockStore.ciStatus, + hasCi: mockStore.hasCI, + sourceBranch: mockStore.targetBranch, + sourceBranchLink: mockStore.targetBranch, + }), + ); + }); + + it('renders deployments', () => { + const expectedProps = mockStore.postMergeDeployments.map(dep => + jasmine.objectContaining({ + deployment: dep, + showMetrics: true, + }), + ); + + const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment'); + + expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js index 17554c4fe42..072e98fc0e8 100644 --- a/spec/javascripts/vue_mr_widget/mock_data.js +++ b/spec/javascripts/vue_mr_widget/mock_data.js @@ -222,3 +222,16 @@ export default { 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775', troubleshooting_docs_path: 'help', }; + +export const mockStore = { + pipeline: { id: 0 }, + mergePipeline: { id: 1 }, + targetBranch: 'target-branch', + sourceBranch: 'source-branch', + sourceBranchLink: 'source-branch-link', + deployments: [{ id: 0, name: 'bogus' }, { id: 1, name: 'bogus-docs' }], + postMergeDeployments: [{ id: 0, name: 'prod' }, { id: 1, name: 'prod-docs' }], + troubleshootingDocsPath: 'troubleshooting-docs-path', + ciStatus: 'ci-status', + hasCI: true, +}; diff --git a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js index 67a3a2e08bc..6add6cdac4d 100644 --- a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -68,4 +68,30 @@ describe('DiffViewer', () => { done(); }); }); + + it('renders renamed component', () => { + createComponent({ + diffMode: 'renamed', + newPath: 'test.abc', + newSha: 'ABC', + oldPath: 'testold.abc', + oldSha: 'DEF', + }); + + expect(vm.$el.textContent).toContain('File moved'); + }); + + it('renders mode changed component', () => { + createComponent({ + diffMode: 'mode_changed', + newPath: 'test.abc', + newSha: 'ABC', + oldPath: 'testold.abc', + oldSha: 'DEF', + aMode: '123', + bMode: '321', + }); + + expect(vm.$el.textContent).toContain('File mode changed from 123 to 321'); + }); }); diff --git a/spec/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js new file mode 100644 index 00000000000..c4358f0d9cb --- /dev/null +++ b/spec/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js @@ -0,0 +1,23 @@ +import { shallowMount } from '@vue/test-utils'; +import ModeChanged from '~/vue_shared/components/diff_viewer/viewers/mode_changed.vue'; + +describe('Diff viewer mode changed component', () => { + let vm; + + beforeEach(() => { + vm = shallowMount(ModeChanged, { + propsData: { + aMode: '123', + bMode: '321', + }, + }); + }); + + afterEach(() => { + vm.destroy(); + }); + + it('renders aMode & bMode', () => { + expect(vm.text()).toContain('File mode changed from 123 to 321'); + }); +}); diff --git a/spec/javascripts/vue_shared/components/notes/timeline_entry_item_spec.js b/spec/javascripts/vue_shared/components/notes/timeline_entry_item_spec.js new file mode 100644 index 00000000000..c15635f2105 --- /dev/null +++ b/spec/javascripts/vue_shared/components/notes/timeline_entry_item_spec.js @@ -0,0 +1,40 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + +describe(TimelineEntryItem.name, () => { + let wrapper; + + const factory = (options = {}) => { + const localVue = createLocalVue(); + + wrapper = shallowMount(TimelineEntryItem, { + localVue, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders correctly', () => { + factory(); + + expect(wrapper.is('.timeline-entry')).toBe(true); + + expect(wrapper.contains('.timeline-entry-inner')).toBe(true); + }); + + it('accepts default slot', () => { + const dummyContent = '<p>some content</p>'; + factory({ + slots: { + default: dummyContent, + }, + }); + + const content = wrapper.find('.timeline-entry-inner :first-child'); + + expect(content.html()).toBe(dummyContent); + }); +}); diff --git a/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb b/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb index df24cef0b8b..91b0499375d 100644 --- a/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/gfm_pipeline_spec.rb @@ -104,5 +104,17 @@ describe Banzai::Pipeline::GfmPipeline do expect(output).to include("src=\"test%20image.png\"") end + + it 'sanitizes the fixed link' do + markdown_xss = "[xss](javascript: alert%28document.domain%29)" + output = described_class.to_html(markdown_xss, project: project) + + expect(output).not_to include("javascript") + + markdown_xss = "<invalidtag>\n[xss](javascript:alert%28document.domain%29)" + output = described_class.to_html(markdown_xss, project: project) + + expect(output).not_to include("javascript") + end end end diff --git a/spec/lib/gitlab/auth/request_authenticator_spec.rb b/spec/lib/gitlab/auth/request_authenticator_spec.rb index 242ab4a91dd..3d979132880 100644 --- a/spec/lib/gitlab/auth/request_authenticator_spec.rb +++ b/spec/lib/gitlab/auth/request_authenticator_spec.rb @@ -19,17 +19,17 @@ describe Gitlab::Auth::RequestAuthenticator do allow_any_instance_of(described_class).to receive(:find_sessionless_user).and_return(sessionless_user) allow_any_instance_of(described_class).to receive(:find_user_from_warden).and_return(session_user) - expect(subject.user).to eq sessionless_user + expect(subject.user([:api])).to eq sessionless_user end it 'returns session user if no sessionless user found' do allow_any_instance_of(described_class).to receive(:find_user_from_warden).and_return(session_user) - expect(subject.user).to eq session_user + expect(subject.user([:api])).to eq session_user end it 'returns nil if no user found' do - expect(subject.user).to be_blank + expect(subject.user([:api])).to be_blank end it 'bubbles up exceptions' do @@ -42,26 +42,26 @@ describe Gitlab::Auth::RequestAuthenticator do let!(:feed_token_user) { build(:user) } it 'returns access_token user first' do - allow_any_instance_of(described_class).to receive(:find_user_from_access_token).and_return(access_token_user) + allow_any_instance_of(described_class).to receive(:find_user_from_web_access_token).and_return(access_token_user) allow_any_instance_of(described_class).to receive(:find_user_from_feed_token).and_return(feed_token_user) - expect(subject.find_sessionless_user).to eq access_token_user + expect(subject.find_sessionless_user([:api])).to eq access_token_user end it 'returns feed_token user if no access_token user found' do allow_any_instance_of(described_class).to receive(:find_user_from_feed_token).and_return(feed_token_user) - expect(subject.find_sessionless_user).to eq feed_token_user + expect(subject.find_sessionless_user([:api])).to eq feed_token_user end it 'returns nil if no user found' do - expect(subject.find_sessionless_user).to be_blank + expect(subject.find_sessionless_user([:api])).to be_blank end it 'rescue Gitlab::Auth::AuthenticationError exceptions' do - allow_any_instance_of(described_class).to receive(:find_user_from_access_token).and_raise(Gitlab::Auth::UnauthorizedError) + allow_any_instance_of(described_class).to receive(:find_user_from_web_access_token).and_raise(Gitlab::Auth::UnauthorizedError) - expect(subject.find_sessionless_user).to be_blank + expect(subject.find_sessionless_user([:api])).to be_blank end end end diff --git a/spec/lib/gitlab/auth/user_auth_finders_spec.rb b/spec/lib/gitlab/auth/user_auth_finders_spec.rb index 454ad1589b9..4e4c8b215c2 100644 --- a/spec/lib/gitlab/auth/user_auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/user_auth_finders_spec.rb @@ -9,7 +9,7 @@ describe Gitlab::Auth::UserAuthFinders do 'rack.input' => '' } end - let(:request) { Rack::Request.new(env)} + let(:request) { Rack::Request.new(env) } def set_param(key, value) request.update_param(key, value) @@ -49,6 +49,7 @@ describe Gitlab::Auth::UserAuthFinders do describe '#find_user_from_feed_token' do context 'when the request format is atom' do before do + env['SCRIPT_NAME'] = 'url.atom' env['HTTP_ACCEPT'] = 'application/atom+xml' end @@ -56,17 +57,17 @@ describe Gitlab::Auth::UserAuthFinders do it 'returns user if valid feed_token' do set_param(:feed_token, user.feed_token) - expect(find_user_from_feed_token).to eq user + expect(find_user_from_feed_token(:rss)).to eq user end it 'returns nil if feed_token is blank' do - expect(find_user_from_feed_token).to be_nil + expect(find_user_from_feed_token(:rss)).to be_nil end it 'returns exception if invalid feed_token' do set_param(:feed_token, 'invalid_token') - expect { find_user_from_feed_token }.to raise_error(Gitlab::Auth::UnauthorizedError) + expect { find_user_from_feed_token(:rss) }.to raise_error(Gitlab::Auth::UnauthorizedError) end end @@ -74,34 +75,38 @@ describe Gitlab::Auth::UserAuthFinders do it 'returns user if valid rssd_token' do set_param(:rss_token, user.feed_token) - expect(find_user_from_feed_token).to eq user + expect(find_user_from_feed_token(:rss)).to eq user end it 'returns nil if rss_token is blank' do - expect(find_user_from_feed_token).to be_nil + expect(find_user_from_feed_token(:rss)).to be_nil end it 'returns exception if invalid rss_token' do set_param(:rss_token, 'invalid_token') - expect { find_user_from_feed_token }.to raise_error(Gitlab::Auth::UnauthorizedError) + expect { find_user_from_feed_token(:rss) }.to raise_error(Gitlab::Auth::UnauthorizedError) end end end context 'when the request format is not atom' do it 'returns nil' do + env['SCRIPT_NAME'] = 'json' + set_param(:feed_token, user.feed_token) - expect(find_user_from_feed_token).to be_nil + expect(find_user_from_feed_token(:rss)).to be_nil end end context 'when the request format is empty' do it 'the method call does not modify the original value' do + env['SCRIPT_NAME'] = 'url.atom' + env.delete('action_dispatch.request.formats') - find_user_from_feed_token + find_user_from_feed_token(:rss) expect(env['action_dispatch.request.formats']).to be_nil end @@ -111,8 +116,12 @@ describe Gitlab::Auth::UserAuthFinders do describe '#find_user_from_access_token' do let(:personal_access_token) { create(:personal_access_token, user: user) } + before do + env['SCRIPT_NAME'] = 'url.atom' + end + it 'returns nil if no access_token present' do - expect(find_personal_access_token).to be_nil + expect(find_user_from_access_token).to be_nil end context 'when validate_access_token! returns valid' do @@ -131,9 +140,59 @@ describe Gitlab::Auth::UserAuthFinders do end end + describe '#find_user_from_web_access_token' do + let(:personal_access_token) { create(:personal_access_token, user: user) } + + before do + env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + end + + it 'returns exception if token has no user' do + allow_any_instance_of(PersonalAccessToken).to receive(:user).and_return(nil) + + expect { find_user_from_access_token }.to raise_error(Gitlab::Auth::UnauthorizedError) + end + + context 'no feed or API requests' do + it 'returns nil if the request is not RSS' do + expect(find_user_from_web_access_token(:rss)).to be_nil + end + + it 'returns nil if the request is not ICS' do + expect(find_user_from_web_access_token(:ics)).to be_nil + end + + it 'returns nil if the request is not API' do + expect(find_user_from_web_access_token(:api)).to be_nil + end + end + + it 'returns the user for RSS requests' do + env['SCRIPT_NAME'] = 'url.atom' + + expect(find_user_from_web_access_token(:rss)).to eq(user) + end + + it 'returns the user for ICS requests' do + env['SCRIPT_NAME'] = 'url.ics' + + expect(find_user_from_web_access_token(:ics)).to eq(user) + end + + it 'returns the user for API requests' do + env['SCRIPT_NAME'] = '/api/endpoint' + + expect(find_user_from_web_access_token(:api)).to eq(user) + end + end + describe '#find_personal_access_token' do let(:personal_access_token) { create(:personal_access_token, user: user) } + before do + env['SCRIPT_NAME'] = 'url.atom' + end + context 'passed as header' do it 'returns token if valid personal_access_token' do env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token @@ -220,5 +279,20 @@ describe Gitlab::Auth::UserAuthFinders do expect { validate_access_token!(scopes: [:sudo]) }.to raise_error(Gitlab::Auth::InsufficientScopeError) end end + + context 'with impersonation token' do + let(:personal_access_token) { create(:personal_access_token, :impersonation, user: user) } + + context 'when impersonation is disabled' do + before do + stub_config_setting(impersonation_enabled: false) + allow_any_instance_of(described_class).to receive(:access_token).and_return(personal_access_token) + end + + it 'returns Gitlab::Auth::ImpersonationDisabled' do + expect { validate_access_token! }.to raise_error(Gitlab::Auth::ImpersonationDisabled) + end + end + end end end diff --git a/spec/lib/gitlab/background_migration/backfill_project_fullpath_in_repo_config_spec.rb b/spec/lib/gitlab/background_migration/backfill_project_fullpath_in_repo_config_spec.rb new file mode 100644 index 00000000000..c66d7cd6148 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_project_fullpath_in_repo_config_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::BackgroundMigration::BackfillProjectFullpathInRepoConfig, :migration, schema: 20181010133639 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:group) { namespaces.create!(name: 'foo', path: 'foo') } + let(:subgroup) { namespaces.create!(name: 'bar', path: 'bar', parent_id: group.id) } + + describe described_class::Storage::HashedProject do + let(:project) { double(id: 555) } + subject(:project_storage) { described_class.new(project) } + + it 'has the correct disk_path' do + expect(project_storage.disk_path).to eq('@hashed/91/a7/91a73fd806ab2c005c13b4dc19130a884e909dea3f72d46e30266fe1a1f588d8') + end + end + + describe described_class::Storage::LegacyProject do + let(:project) { double(full_path: 'this/is/the/full/path') } + subject(:project_storage) { described_class.new(project) } + + it 'has the correct disk_path' do + expect(project_storage.disk_path).to eq('this/is/the/full/path') + end + end + + describe described_class::Project do + let(:project_record) { projects.create!(namespace_id: subgroup.id, name: 'baz', path: 'baz') } + subject(:project) { described_class.find(project_record.id) } + + describe '#full_path' do + it 'returns path containing all parent namespaces' do + expect(project.full_path).to eq('foo/bar/baz') + end + + it 'raises OrphanedNamespaceError when any parent namespace does not exist' do + subgroup.update_attribute(:parent_id, namespaces.maximum(:id).succ) + + expect { project.full_path }.to raise_error(Gitlab::BackgroundMigration::BackfillProjectFullpathInRepoConfig::OrphanedNamespaceError) + end + end + end + + describe described_class::Up do + describe '#perform' do + subject(:migrate) { described_class.new.perform(projects.minimum(:id), projects.maximum(:id)) } + + it 'asks the gitaly client to set config' do + projects.create!(namespace_id: subgroup.id, name: 'baz', path: 'baz') + projects.create!(namespace_id: subgroup.id, name: 'buzz', path: 'buzz', storage_version: 1) + + expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |repository_service| + allow(repository_service).to receive(:cleanup) + expect(repository_service).to receive(:set_config).with('gitlab.fullpath' => 'foo/bar/baz') + end + + expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |repository_service| + allow(repository_service).to receive(:cleanup) + expect(repository_service).to receive(:set_config).with('gitlab.fullpath' => 'foo/bar/buzz') + end + + migrate + end + end + end + + describe described_class::Down do + describe '#perform' do + subject(:migrate) { described_class.new.perform(projects.minimum(:id), projects.maximum(:id)) } + + it 'asks the gitaly client to set config' do + projects.create!(namespace_id: subgroup.id, name: 'baz', path: 'baz') + + expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |repository_service| + allow(repository_service).to receive(:cleanup) + expect(repository_service).to receive(:delete_config).with(['gitlab.fullpath']) + end + + migrate + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/encrypt_runners_tokens_spec.rb b/spec/lib/gitlab/background_migration/encrypt_runners_tokens_spec.rb new file mode 100644 index 00000000000..9d4921968b3 --- /dev/null +++ b/spec/lib/gitlab/background_migration/encrypt_runners_tokens_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe Gitlab::BackgroundMigration::EncryptRunnersTokens, :migration, schema: 20181121111200 do + let(:settings) { table(:application_settings) } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:runners) { table(:ci_runners) } + + context 'when migrating application settings' do + before do + settings.create!(id: 1, runners_registration_token: 'plain-text-token1') + end + + it 'migrates runners registration tokens' do + migrate!(:settings, 1, 1) + + encrypted_token = settings.first.runners_registration_token_encrypted + decrypted_token = ::Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) + + expect(decrypted_token).to eq 'plain-text-token1' + expect(settings.first.runners_registration_token).to eq 'plain-text-token1' + end + end + + context 'when migrating namespaces' do + before do + namespaces.create!(id: 11, name: 'gitlab', path: 'gitlab-org', runners_token: 'my-token1') + namespaces.create!(id: 12, name: 'gitlab', path: 'gitlab-org', runners_token: 'my-token2') + namespaces.create!(id: 22, name: 'gitlab', path: 'gitlab-org', runners_token: 'my-token3') + end + + it 'migrates runners registration tokens' do + migrate!(:namespace, 11, 22) + + expect(namespaces.all.reload).to all( + have_attributes(runners_token: be_a(String), runners_token_encrypted: be_a(String)) + ) + end + end + + context 'when migrating projects' do + before do + namespaces.create!(id: 11, name: 'gitlab', path: 'gitlab-org') + projects.create!(id: 111, namespace_id: 11, name: 'gitlab', path: 'gitlab-ce', runners_token: 'my-token1') + projects.create!(id: 114, namespace_id: 11, name: 'gitlab', path: 'gitlab-ce', runners_token: 'my-token2') + projects.create!(id: 116, namespace_id: 11, name: 'gitlab', path: 'gitlab-ce', runners_token: 'my-token3') + end + + it 'migrates runners registration tokens' do + migrate!(:project, 111, 116) + + expect(projects.all.reload).to all( + have_attributes(runners_token: be_a(String), runners_token_encrypted: be_a(String)) + ) + end + end + + context 'when migrating runners' do + before do + runners.create!(id: 201, runner_type: 1, token: 'plain-text-token1') + runners.create!(id: 202, runner_type: 1, token: 'plain-text-token2') + runners.create!(id: 203, runner_type: 1, token: 'plain-text-token3') + end + + it 'migrates runners communication tokens' do + migrate!(:runner, 201, 203) + + expect(runners.all.reload).to all( + have_attributes(token: be_a(String), token_encrypted: be_a(String)) + ) + end + end + + def migrate!(model, from, to) + subject.perform(model, from, to) + end +end diff --git a/spec/lib/gitlab/branch_push_merge_commit_analyzer_spec.rb b/spec/lib/gitlab/branch_push_merge_commit_analyzer_spec.rb new file mode 100644 index 00000000000..1e969542975 --- /dev/null +++ b/spec/lib/gitlab/branch_push_merge_commit_analyzer_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::BranchPushMergeCommitAnalyzer do + let(:project) { create(:project, :repository) } + let(:oldrev) { 'merge-commit-analyze-before' } + let(:newrev) { 'merge-commit-analyze-after' } + let(:commits) { project.repository.commits_between(oldrev, newrev).reverse } + + subject { described_class.new(commits) } + + describe '#get_merge_commit' do + let(:expected_merge_commits) do + { + '646ece5cfed840eca0a4feb21bcd6a81bb19bda3' => '646ece5cfed840eca0a4feb21bcd6a81bb19bda3', + '29284d9bcc350bcae005872d0be6edd016e2efb5' => '29284d9bcc350bcae005872d0be6edd016e2efb5', + '5f82584f0a907f3b30cfce5bb8df371454a90051' => '29284d9bcc350bcae005872d0be6edd016e2efb5', + '8a994512e8c8f0dfcf22bb16df6e876be7a61036' => '29284d9bcc350bcae005872d0be6edd016e2efb5', + '689600b91aabec706e657e38ea706ece1ee8268f' => '29284d9bcc350bcae005872d0be6edd016e2efb5', + 'db46a1c5a5e474aa169b6cdb7a522d891bc4c5f9' => 'db46a1c5a5e474aa169b6cdb7a522d891bc4c5f9' + } + end + + it 'returns correct merge commit SHA for each commit' do + expected_merge_commits.each do |commit, merge_commit| + expect(subject.get_merge_commit(commit)).to eq(merge_commit) + end + end + + context 'when one parent has two children' do + let(:oldrev) { '1adbdefe31288f3bbe4b614853de4908a0b6f792' } + let(:newrev) { '5f82584f0a907f3b30cfce5bb8df371454a90051' } + + let(:expected_merge_commits) do + { + '5f82584f0a907f3b30cfce5bb8df371454a90051' => '5f82584f0a907f3b30cfce5bb8df371454a90051', + '8a994512e8c8f0dfcf22bb16df6e876be7a61036' => '5f82584f0a907f3b30cfce5bb8df371454a90051', + '689600b91aabec706e657e38ea706ece1ee8268f' => '689600b91aabec706e657e38ea706ece1ee8268f' + } + end + + it 'returns correct merge commit SHA for each commit' do + expected_merge_commits.each do |commit, merge_commit| + expect(subject.get_merge_commit(commit)).to eq(merge_commit) + end + end + end + + context 'when relevant_commit_ids is provided' do + let(:relevant_commit_id) { '8a994512e8c8f0dfcf22bb16df6e876be7a61036' } + subject { described_class.new(commits, relevant_commit_ids: [relevant_commit_id]) } + + it 'returns correct merge commit' do + expected_merge_commits.each do |commit, merge_commit| + subject = described_class.new(commits, relevant_commit_ids: [commit]) + expect(subject.get_merge_commit(commit)).to eq(merge_commit) + end + end + end + end +end diff --git a/spec/lib/gitlab/checks/branch_check_spec.rb b/spec/lib/gitlab/checks/branch_check_spec.rb new file mode 100644 index 00000000000..77366e91dca --- /dev/null +++ b/spec/lib/gitlab/checks/branch_check_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Checks::BranchCheck do + include_context 'change access checks context' + + describe '#validate!' do + it 'does not raise any error' do + expect { subject.validate! }.not_to raise_error + end + + context 'trying to delete the default branch' do + let(:newrev) { '0000000000000000000000000000000000000000' } + let(:ref) { 'refs/heads/master' } + + it 'raises an error' do + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'The default branch of a project cannot be deleted.') + end + end + + context 'protected branches check' do + before do + allow(ProtectedBranch).to receive(:protected?).with(project, 'master').and_return(true) + allow(ProtectedBranch).to receive(:protected?).with(project, 'feature').and_return(true) + end + + it 'raises an error if the user is not allowed to do forced pushes to protected branches' do + expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true) + + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to force push code to a protected branch on this project.') + end + + it 'raises an error if the user is not allowed to merge to protected branches' do + expect_any_instance_of(Gitlab::Checks::MatchingMergeRequest).to receive(:match?).and_return(true) + expect(user_access).to receive(:can_merge_to_branch?).and_return(false) + expect(user_access).to receive(:can_push_to_branch?).and_return(false) + + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to merge code into protected branches on this project.') + end + + it 'raises an error if the user is not allowed to push to protected branches' do + expect(user_access).to receive(:can_push_to_branch?).and_return(false) + + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to protected branches on this project.') + end + + context 'when project repository is empty' do + let(:project) { create(:project) } + + it 'raises an error if the user is not allowed to push to protected branches' do + expect(user_access).to receive(:can_push_to_branch?).and_return(false) + + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /Ask a project Owner or Maintainer to create a default branch/) + end + end + + context 'branch deletion' do + let(:newrev) { '0000000000000000000000000000000000000000' } + let(:ref) { 'refs/heads/feature' } + + context 'if the user is not allowed to delete protected branches' do + it 'raises an error' do + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to delete protected branches from this project. Only a project maintainer or owner can delete a protected branch.') + end + end + + context 'if the user is allowed to delete protected branches' do + before do + project.add_maintainer(user) + end + + context 'through the web interface' do + let(:protocol) { 'web' } + + it 'allows branch deletion' do + expect { subject.validate! }.not_to raise_error + end + end + + context 'over SSH or HTTP' do + it 'raises an error' do + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You can only delete protected branches using the web interface.') + end + end + end + end + end + end +end diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb index 81804ba5c76..45fb33e9e4a 100644 --- a/spec/lib/gitlab/checks/change_access_spec.rb +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -2,245 +2,56 @@ require 'spec_helper' describe Gitlab::Checks::ChangeAccess do describe '#exec' do - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - let(:user_access) { Gitlab::UserAccess.new(user, project: project) } - let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' } - let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } - let(:ref) { 'refs/heads/master' } - let(:changes) { { oldrev: oldrev, newrev: newrev, ref: ref } } - let(:protocol) { 'ssh' } - let(:timeout) { Gitlab::GitAccess::INTERNAL_TIMEOUT } - let(:logger) { Gitlab::Checks::TimedLogger.new(timeout: timeout) } + include_context 'change access checks context' - subject(:change_access) do - described_class.new( - changes, - project: project, - user_access: user_access, - protocol: protocol, - logger: logger - ) - end - - before do - project.add_developer(user) - end + subject { change_access } context 'without failed checks' do it "doesn't raise an error" do expect { subject.exec }.not_to raise_error end - end - context 'when time limit was reached' do - it 'raises a TimeoutError' do - logger = Gitlab::Checks::TimedLogger.new(start_time: timeout.ago, timeout: timeout) - access = described_class.new(changes, - project: project, - user_access: user_access, - protocol: protocol, - logger: logger) + it 'calls pushes checks' do + expect_any_instance_of(Gitlab::Checks::PushCheck).to receive(:validate!) - expect { access.exec }.to raise_error(Gitlab::Checks::TimedLogger::TimeoutError) + subject.exec end - end - context 'when the user is not allowed to push to the repo' do - it 'raises an error' do - expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false) - expect(user_access).to receive(:can_push_to_branch?).with('master').and_return(false) + it 'calls branches checks' do + expect_any_instance_of(Gitlab::Checks::BranchCheck).to receive(:validate!) - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.') + subject.exec end - end - context 'tags check' do - let(:ref) { 'refs/tags/v1.0.0' } + it 'calls tags checks' do + expect_any_instance_of(Gitlab::Checks::TagCheck).to receive(:validate!) - it 'raises an error if the user is not allowed to update tags' do - allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) - expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false) - - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to change existing tags on this project.') + subject.exec end - context 'with protected tag' do - let!(:protected_tag) { create(:protected_tag, project: project, name: 'v*') } - - context 'as maintainer' do - before do - project.add_maintainer(user) - end + it 'calls lfs checks' do + expect_any_instance_of(Gitlab::Checks::LfsCheck).to receive(:validate!) - context 'deletion' do - let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' } - let(:newrev) { '0000000000000000000000000000000000000000' } - - it 'is prevented' do - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be deleted/) - end - end - - context 'update' do - let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' } - let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } - - it 'is prevented' do - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be updated/) - end - end - end - - context 'creation' do - let(:oldrev) { '0000000000000000000000000000000000000000' } - let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } - let(:ref) { 'refs/tags/v9.1.0' } - - it 'prevents creation below access level' do - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /allowed to create this tag as it is protected/) - end - - context 'when user has access' do - let!(:protected_tag) { create(:protected_tag, :developers_can_create, project: project, name: 'v*') } - - it 'allows tag creation' do - expect { subject.exec }.not_to raise_error - end - end - end + subject.exec end - end - context 'branches check' do - context 'trying to delete the default branch' do - let(:newrev) { '0000000000000000000000000000000000000000' } - let(:ref) { 'refs/heads/master' } + it 'calls diff checks' do + expect_any_instance_of(Gitlab::Checks::DiffCheck).to receive(:validate!) - it 'raises an error' do - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'The default branch of a project cannot be deleted.') - end - end - - context 'protected branches check' do - before do - allow(ProtectedBranch).to receive(:protected?).with(project, 'master').and_return(true) - allow(ProtectedBranch).to receive(:protected?).with(project, 'feature').and_return(true) - end - - it 'raises an error if the user is not allowed to do forced pushes to protected branches' do - expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true) - - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to force push code to a protected branch on this project.') - end - - it 'raises an error if the user is not allowed to merge to protected branches' do - expect_any_instance_of(Gitlab::Checks::MatchingMergeRequest).to receive(:match?).and_return(true) - expect(user_access).to receive(:can_merge_to_branch?).and_return(false) - expect(user_access).to receive(:can_push_to_branch?).and_return(false) - - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to merge code into protected branches on this project.') - end - - it 'raises an error if the user is not allowed to push to protected branches' do - expect(user_access).to receive(:can_push_to_branch?).and_return(false) - - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to protected branches on this project.') - end - - context 'when project repository is empty' do - let(:project) { create(:project) } - - it 'raises an error if the user is not allowed to push to protected branches' do - expect(user_access).to receive(:can_push_to_branch?).and_return(false) - - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /Ask a project Owner or Maintainer to create a default branch/) - end - end - - context 'branch deletion' do - let(:newrev) { '0000000000000000000000000000000000000000' } - let(:ref) { 'refs/heads/feature' } - - context 'if the user is not allowed to delete protected branches' do - it 'raises an error' do - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to delete protected branches from this project. Only a project maintainer or owner can delete a protected branch.') - end - end - - context 'if the user is allowed to delete protected branches' do - before do - project.add_maintainer(user) - end - - context 'through the web interface' do - let(:protocol) { 'web' } - - it 'allows branch deletion' do - expect { subject.exec }.not_to raise_error - end - end - - context 'over SSH or HTTP' do - it 'raises an error' do - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You can only delete protected branches using the web interface.') - end - end - end - end + subject.exec end end - context 'LFS integrity check' do - it 'fails if any LFS blobs are missing' do - allow_any_instance_of(Gitlab::Checks::LfsIntegrity).to receive(:objects_missing?).and_return(true) - - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /LFS objects are missing/) - end - - it 'succeeds if LFS objects have already been uploaded' do - allow_any_instance_of(Gitlab::Checks::LfsIntegrity).to receive(:objects_missing?).and_return(false) - - expect { subject.exec }.not_to raise_error - end - end - - context 'LFS file lock check' do - let(:owner) { create(:user) } - let!(:lock) { create(:lfs_file_lock, user: owner, project: project, path: 'README') } - - before do - allow(project.repository).to receive(:new_commits).and_return( - project.repository.commits_between('be93687618e4b132087f430a4d8fc3a609c9b77c', '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51') - ) - end - - context 'with LFS not enabled' do - it 'skips the validation' do - expect_any_instance_of(Gitlab::Checks::CommitCheck).not_to receive(:validate) - - subject.exec - end - end - - context 'with LFS enabled' do - before do - allow(project).to receive(:lfs_enabled?).and_return(true) - end - - context 'when change is sent by a different user' do - it 'raises an error if the user is not allowed to update the file' do - expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, "The path 'README' is locked in Git LFS by #{lock.user.name}") - end - end - - context 'when change is sent by the author of the lock' do - let(:user) { owner } + context 'when time limit was reached' do + it 'raises a TimeoutError' do + logger = Gitlab::Checks::TimedLogger.new(start_time: timeout.ago, timeout: timeout) + access = described_class.new(changes, + project: project, + user_access: user_access, + protocol: protocol, + logger: logger) - it "doesn't raise any error" do - expect { subject.exec }.not_to raise_error - end - end + expect { access.exec }.to raise_error(Gitlab::Checks::TimedLogger::TimeoutError) end end end diff --git a/spec/lib/gitlab/checks/diff_check_spec.rb b/spec/lib/gitlab/checks/diff_check_spec.rb new file mode 100644 index 00000000000..eeec1e83179 --- /dev/null +++ b/spec/lib/gitlab/checks/diff_check_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Checks::DiffCheck do + include_context 'change access checks context' + + describe '#validate!' do + let(:owner) { create(:user) } + let!(:lock) { create(:lfs_file_lock, user: owner, project: project, path: 'README') } + + before do + allow(project.repository).to receive(:new_commits).and_return( + project.repository.commits_between('be93687618e4b132087f430a4d8fc3a609c9b77c', '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51') + ) + end + + context 'with LFS not enabled' do + before do + allow(project).to receive(:lfs_enabled?).and_return(false) + end + + it 'skips the validation' do + expect(subject).not_to receive(:validate_diff) + expect(subject).not_to receive(:validate_file_paths) + + subject.validate! + end + end + + context 'with LFS enabled' do + before do + allow(project).to receive(:lfs_enabled?).and_return(true) + end + + context 'when change is sent by a different user' do + it 'raises an error if the user is not allowed to update the file' do + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, "The path 'README' is locked in Git LFS by #{lock.user.name}") + end + end + + context 'when change is sent by the author of the lock' do + let(:user) { owner } + + it "doesn't raise any error" do + expect { subject.validate! }.not_to raise_error + end + end + end + end +end diff --git a/spec/lib/gitlab/checks/lfs_check_spec.rb b/spec/lib/gitlab/checks/lfs_check_spec.rb new file mode 100644 index 00000000000..35f8069c8a4 --- /dev/null +++ b/spec/lib/gitlab/checks/lfs_check_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Checks::LfsCheck do + include_context 'change access checks context' + + let(:blob_object) { project.repository.blob_at_branch('lfs', 'files/lfs/lfs_object.iso') } + + before do + allow_any_instance_of(Gitlab::Git::LfsChanges).to receive(:new_pointers) do + [blob_object] + end + end + + describe '#validate!' do + context 'with LFS not enabled' do + it 'skips integrity check' do + expect_any_instance_of(Gitlab::Git::LfsChanges).not_to receive(:new_pointers) + + subject.validate! + end + end + + context 'with LFS enabled' do + before do + allow(project).to receive(:lfs_enabled?).and_return(true) + end + + context 'deletion' do + let(:changes) { { oldrev: oldrev, ref: ref } } + + it 'skips integrity check' do + expect(project.repository).not_to receive(:new_objects) + + subject.validate! + end + end + + it 'fails if any LFS blobs are missing' do + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /LFS objects are missing/) + end + + it 'succeeds if LFS objects have already been uploaded' do + lfs_object = create(:lfs_object, oid: blob_object.lfs_oid) + create(:lfs_objects_project, project: project, lfs_object: lfs_object) + + expect { subject.validate! }.not_to raise_error + end + end + end +end diff --git a/spec/lib/gitlab/checks/push_check_spec.rb b/spec/lib/gitlab/checks/push_check_spec.rb new file mode 100644 index 00000000000..25f0d428cb9 --- /dev/null +++ b/spec/lib/gitlab/checks/push_check_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Checks::PushCheck do + include_context 'change access checks context' + + describe '#validate!' do + it 'does not raise any error' do + expect { subject.validate! }.not_to raise_error + end + + context 'when the user is not allowed to push to the repo' do + it 'raises an error' do + expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false) + expect(user_access).to receive(:can_push_to_branch?).with('master').and_return(false) + + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.') + end + end + end +end diff --git a/spec/lib/gitlab/checks/tag_check_spec.rb b/spec/lib/gitlab/checks/tag_check_spec.rb new file mode 100644 index 00000000000..b1258270611 --- /dev/null +++ b/spec/lib/gitlab/checks/tag_check_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Checks::TagCheck do + include_context 'change access checks context' + + describe '#validate!' do + let(:ref) { 'refs/tags/v1.0.0' } + + it 'raises an error' do + allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) + expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false) + + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to change existing tags on this project.') + end + + context 'with protected tag' do + let!(:protected_tag) { create(:protected_tag, project: project, name: 'v*') } + + context 'as maintainer' do + before do + project.add_maintainer(user) + end + + context 'deletion' do + let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' } + let(:newrev) { '0000000000000000000000000000000000000000' } + + it 'is prevented' do + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be deleted/) + end + end + + context 'update' do + let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' } + let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + + it 'is prevented' do + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be updated/) + end + end + end + + context 'creation' do + let(:oldrev) { '0000000000000000000000000000000000000000' } + let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + let(:ref) { 'refs/tags/v9.1.0' } + + it 'prevents creation below access level' do + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /allowed to create this tag as it is protected/) + end + + context 'when user has access' do + let!(:protected_tag) { create(:protected_tag, :developers_can_create, project: project, name: 'v*') } + + it 'allows tag creation' do + expect { subject.validate! }.not_to raise_error + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/except_policy_spec.rb b/spec/lib/gitlab/ci/config/entry/except_policy_spec.rb new file mode 100644 index 00000000000..d036bf2f4d1 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/except_policy_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::ExceptPolicy do + let(:entry) { described_class.new(config) } + + it_behaves_like 'correct only except policy' + + describe '.default' do + it 'does not have a default value' do + expect(described_class.default).to be_nil + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb index 7c18514934e..12f4b9dc624 100644 --- a/spec/lib/gitlab/ci/config/entry/global_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb @@ -160,7 +160,8 @@ describe Gitlab::Ci::Config::Entry::Global do cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push' }, variables: { 'VAR' => 'value' }, ignore: false, - after_script: ['make clean'] }, + after_script: ['make clean'], + only: { refs: %w[branches tags] } }, spinach: { name: :spinach, before_script: [], script: %w[spinach], @@ -171,7 +172,8 @@ describe Gitlab::Ci::Config::Entry::Global do cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push' }, variables: {}, ignore: false, - after_script: ['make clean'] } + after_script: ['make clean'], + only: { refs: %w[branches tags] } } ) end end diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index 57d4577a90c..c1f4a060063 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -258,7 +258,8 @@ describe Gitlab::Ci::Config::Entry::Job do commands: "ls\npwd\nrspec", stage: 'test', ignore: false, - after_script: %w[cleanup]) + after_script: %w[cleanup], + only: { refs: %w[branches tags] }) end end end diff --git a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb index c0a2b6517e3..2a753408f54 100644 --- a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb @@ -67,12 +67,14 @@ describe Gitlab::Ci::Config::Entry::Jobs do script: %w[rspec], commands: 'rspec', ignore: false, - stage: 'test' }, + stage: 'test', + only: { refs: %w[branches tags] } }, spinach: { name: :spinach, script: %w[spinach], commands: 'spinach', ignore: false, - stage: 'test' }) + stage: 'test', + only: { refs: %w[branches tags] } }) end end diff --git a/spec/lib/gitlab/ci/config/entry/only_policy_spec.rb b/spec/lib/gitlab/ci/config/entry/only_policy_spec.rb new file mode 100644 index 00000000000..5518b68e51a --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/only_policy_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::OnlyPolicy do + let(:entry) { described_class.new(config) } + + it_behaves_like 'correct only except policy' + + describe '.default' do + it 'haa a default value' do + expect(described_class.default).to eq( { refs: %w[branches tags] } ) + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/policy_spec.rb b/spec/lib/gitlab/ci/config/entry/policy_spec.rb index 83001b7fdd8..cf40a22af2e 100644 --- a/spec/lib/gitlab/ci/config/entry/policy_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/policy_spec.rb @@ -1,173 +1,8 @@ -require 'fast_spec_helper' -require_dependency 'active_model' +require 'spec_helper' describe Gitlab::Ci::Config::Entry::Policy do let(:entry) { described_class.new(config) } - context 'when using simplified policy' do - describe 'validations' do - context 'when entry config value is valid' do - context 'when config is a branch or tag name' do - let(:config) { %w[master feature/branch] } - - describe '#valid?' do - it 'is valid' do - expect(entry).to be_valid - end - end - - describe '#value' do - it 'returns refs hash' do - expect(entry.value).to eq(refs: config) - end - end - end - - context 'when config is a regexp' do - let(:config) { ['/^issue-.*$/'] } - - describe '#valid?' do - it 'is valid' do - expect(entry).to be_valid - end - end - end - - context 'when config is a special keyword' do - let(:config) { %w[tags triggers branches] } - - describe '#valid?' do - it 'is valid' do - expect(entry).to be_valid - end - end - end - end - - context 'when entry value is not valid' do - let(:config) { [1] } - - describe '#errors' do - it 'saves errors' do - expect(entry.errors) - .to include /policy config should be an array of strings or regexps/ - end - end - end - end - end - - context 'when using complex policy' do - context 'when specifying refs policy' do - let(:config) { { refs: ['master'] } } - - it 'is a correct configuraton' do - expect(entry).to be_valid - expect(entry.value).to eq(refs: %w[master]) - end - end - - context 'when specifying kubernetes policy' do - let(:config) { { kubernetes: 'active' } } - - it 'is a correct configuraton' do - expect(entry).to be_valid - expect(entry.value).to eq(kubernetes: 'active') - end - end - - context 'when specifying invalid kubernetes policy' do - let(:config) { { kubernetes: 'something' } } - - it 'reports an error about invalid policy' do - expect(entry.errors).to include /unknown value: something/ - 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 expression' do - expect(entry.errors).to include /invalid expression syntax/ - end - end - - context 'when using invalid variables expressions regexp' do - let(:config) { { variables: ['$MY_VAR =~ /some ( thing/'] } } - - it 'reports an error about invalid expression' do - expect(entry.errors).to include /invalid expression syntax/ - end - end - - context 'when specifying a valid changes policy' do - let(:config) { { changes: %w[some/* paths/**/*.rb] } } - - it 'is a correct configuraton' do - expect(entry).to be_valid - expect(entry.value).to eq(config) - end - end - - context 'when changes policy is invalid' do - let(:config) { { changes: [1, 2] } } - - it 'returns errors' do - expect(entry.errors).to include /changes should be an array of strings/ - end - end - - context 'when specifying unknown policy' do - let(:config) { { refs: ['master'], invalid: :something } } - - it 'returns error about invalid key' do - expect(entry.errors).to include /unknown keys: invalid/ - end - end - - context 'when policy is empty' do - let(:config) { {} } - - it 'is not a valid configuration' do - expect(entry.errors).to include /can't be blank/ - end - end - end - - context 'when policy strategy does not match' do - let(:config) { 'string strategy' } - - it 'returns information about errors' do - expect(entry.errors) - .to include /has to be either an array of conditions or a hash/ - end - end - describe '.default' do it 'does not have a default value' do expect(described_class.default).to be_nil diff --git a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb index 85d73e5c382..fab071405df 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb @@ -18,6 +18,7 @@ describe Gitlab::Ci::Pipeline::Chain::Build do before_sha: nil, trigger_request: nil, schedule: nil, + merge_request: nil, project: project, current_user: user, variables_attributes: variables_attributes) @@ -76,6 +77,7 @@ describe Gitlab::Ci::Pipeline::Chain::Build do before_sha: nil, trigger_request: nil, schedule: nil, + merge_request: nil, project: project, current_user: user) end @@ -90,4 +92,31 @@ describe Gitlab::Ci::Pipeline::Chain::Build do expect(pipeline).to be_tag end end + + context 'when pipeline is running for a merge request' do + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new( + source: :merge_request, + origin_ref: 'feature', + checkout_sha: project.commit.id, + after_sha: nil, + before_sha: nil, + trigger_request: nil, + schedule: nil, + merge_request: merge_request, + project: project, + current_user: user) + end + + let(:merge_request) { build(:merge_request, target_project: project) } + + before do + step.perform! + end + + it 'correctly indicated that this is a merge request pipeline' do + expect(pipeline).to be_merge_request + expect(pipeline.merge_request).to eq(merge_request) + end + end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb index a8dc5356413..053bc421649 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb @@ -106,4 +106,34 @@ describe Gitlab::Ci::Pipeline::Chain::Validate::Config do expect(step.break?).to be false end end + + context 'when pipeline source is merge request' do + before do + stub_ci_pipeline_yaml_file(YAML.dump(config)) + end + + let(:pipeline) { build_stubbed(:ci_pipeline, project: project) } + + let(:merge_request_pipeline) do + build(:ci_pipeline, source: :merge_request, project: project) + end + + let(:chain) { described_class.new(merge_request_pipeline, command).tap(&:perform!) } + + context "when config contains 'merge_requests' keyword" do + let(:config) { { rspec: { script: 'echo', only: ['merge_requests'] } } } + + it 'does not break the chain' do + expect(chain).not_to be_break + end + end + + context "when config contains 'merge_request' keyword" do + let(:config) { { rspec: { script: 'echo', only: ['merge_request'] } } } + + it 'does not break the chain' do + expect(chain).not_to be_break + end + end + end end diff --git a/spec/lib/gitlab/ci/config/entry/attributable_spec.rb b/spec/lib/gitlab/config/entry/attributable_spec.rb index b028b771375..abb4fff3ad7 100644 --- a/spec/lib/gitlab/ci/config/entry/attributable_spec.rb +++ b/spec/lib/gitlab/config/entry/attributable_spec.rb @@ -1,9 +1,9 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Entry::Attributable do +describe Gitlab::Config::Entry::Attributable do let(:node) do Class.new do - include Gitlab::Ci::Config::Entry::Attributable + include Gitlab::Config::Entry::Attributable end end @@ -48,7 +48,7 @@ describe Gitlab::Ci::Config::Entry::Attributable do it 'raises an error' do expectation = expect do Class.new(String) do - include Gitlab::Ci::Config::Entry::Attributable + include Gitlab::Config::Entry::Attributable attributes :length end diff --git a/spec/lib/gitlab/ci/config/entry/boolean_spec.rb b/spec/lib/gitlab/config/entry/boolean_spec.rb index 5f067cad93c..1b7a3f850ec 100644 --- a/spec/lib/gitlab/ci/config/entry/boolean_spec.rb +++ b/spec/lib/gitlab/config/entry/boolean_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Entry::Boolean do +describe Gitlab::Config::Entry::Boolean do let(:entry) { described_class.new(config) } describe 'validations' do diff --git a/spec/lib/gitlab/ci/config/entry/configurable_spec.rb b/spec/lib/gitlab/config/entry/configurable_spec.rb index 088d4b472da..85a7cf1d241 100644 --- a/spec/lib/gitlab/ci/config/entry/configurable_spec.rb +++ b/spec/lib/gitlab/config/entry/configurable_spec.rb @@ -1,9 +1,9 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Entry::Configurable do +describe Gitlab::Config::Entry::Configurable do let(:entry) do - Class.new(Gitlab::Ci::Config::Entry::Node) do - include Gitlab::Ci::Config::Entry::Configurable + Class.new(Gitlab::Config::Entry::Node) do + include Gitlab::Config::Entry::Configurable end end @@ -39,7 +39,7 @@ describe Gitlab::Ci::Config::Entry::Configurable do it 'creates a node factory' do expect(entry.nodes[:object]) - .to be_an_instance_of Gitlab::Ci::Config::Entry::Factory + .to be_an_instance_of Gitlab::Config::Entry::Factory end it 'returns a duplicated factory object' do diff --git a/spec/lib/gitlab/ci/config/entry/factory_spec.rb b/spec/lib/gitlab/config/entry/factory_spec.rb index 8dd48e4efae..c29d17eaee3 100644 --- a/spec/lib/gitlab/ci/config/entry/factory_spec.rb +++ b/spec/lib/gitlab/config/entry/factory_spec.rb @@ -1,9 +1,17 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Entry::Factory do +describe Gitlab::Config::Entry::Factory do describe '#create!' do + class Script < Gitlab::Config::Entry::Node + include Gitlab::Config::Entry::Validatable + + validations do + validates :config, array_of_strings: true + end + end + + let(:entry) { Script } let(:factory) { described_class.new(entry) } - let(:entry) { Gitlab::Ci::Config::Entry::Script } context 'when setting a concrete value' do it 'creates entry with valid value' do @@ -54,7 +62,7 @@ describe Gitlab::Ci::Config::Entry::Factory do context 'when not setting a value' do it 'raises error' do expect { factory.create! }.to raise_error( - Gitlab::Ci::Config::Entry::Factory::InvalidFactory + Gitlab::Config::Entry::Factory::InvalidFactory ) end end diff --git a/spec/lib/gitlab/ci/config/entry/simplifiable_spec.rb b/spec/lib/gitlab/config/entry/simplifiable_spec.rb index 395062207a3..bc8387ada67 100644 --- a/spec/lib/gitlab/ci/config/entry/simplifiable_spec.rb +++ b/spec/lib/gitlab/config/entry/simplifiable_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Entry::Simplifiable do +describe Gitlab::Config::Entry::Simplifiable do describe '.strategy' do let(:entry) do Class.new(described_class) do diff --git a/spec/lib/gitlab/ci/config/entry/undefined_spec.rb b/spec/lib/gitlab/config/entry/undefined_spec.rb index fdf48d84192..48f9d276c95 100644 --- a/spec/lib/gitlab/ci/config/entry/undefined_spec.rb +++ b/spec/lib/gitlab/config/entry/undefined_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Entry::Undefined do +describe Gitlab::Config::Entry::Undefined do let(:entry) { described_class.new } describe '#leaf?' do diff --git a/spec/lib/gitlab/ci/config/entry/unspecified_spec.rb b/spec/lib/gitlab/config/entry/unspecified_spec.rb index 66f88fa35b6..64421824a12 100644 --- a/spec/lib/gitlab/ci/config/entry/unspecified_spec.rb +++ b/spec/lib/gitlab/config/entry/unspecified_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Entry::Unspecified do +describe Gitlab::Config::Entry::Unspecified do let(:unspecified) { described_class.new(entry) } let(:entry) { spy('Entry') } diff --git a/spec/lib/gitlab/ci/config/entry/validatable_spec.rb b/spec/lib/gitlab/config/entry/validatable_spec.rb index ae2a7a51ba6..5a8f9766d23 100644 --- a/spec/lib/gitlab/ci/config/entry/validatable_spec.rb +++ b/spec/lib/gitlab/config/entry/validatable_spec.rb @@ -1,9 +1,9 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Entry::Validatable do +describe Gitlab::Config::Entry::Validatable do let(:entry) do - Class.new(Gitlab::Ci::Config::Entry::Node) do - include Gitlab::Ci::Config::Entry::Validatable + Class.new(Gitlab::Config::Entry::Node) do + include Gitlab::Config::Entry::Validatable end end @@ -20,7 +20,7 @@ describe Gitlab::Ci::Config::Entry::Validatable do it 'returns validator' do expect(entry.validator.superclass) - .to be Gitlab::Ci::Config::Entry::Validator + .to be Gitlab::Config::Entry::Validator end it 'returns only one validator to mitigate leaks' do diff --git a/spec/lib/gitlab/ci/config/entry/validator_spec.rb b/spec/lib/gitlab/config/entry/validator_spec.rb index 172b6b47a4f..efa16c4265c 100644 --- a/spec/lib/gitlab/ci/config/entry/validator_spec.rb +++ b/spec/lib/gitlab/config/entry/validator_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Entry::Validator do +describe Gitlab::Config::Entry::Validator do let(:validator) { Class.new(described_class) } let(:validator_instance) { validator.new(node) } let(:node) { spy('node') } diff --git a/spec/lib/gitlab/ci/config/loader_spec.rb b/spec/lib/gitlab/config/loader/yaml_spec.rb index 590fc8904c1..44c9a3896a8 100644 --- a/spec/lib/gitlab/ci/config/loader_spec.rb +++ b/spec/lib/gitlab/config/loader/yaml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Loader do +describe Gitlab::Config::Loader::Yaml do let(:loader) { described_class.new(yml) } context 'when yaml syntax is correct' do @@ -31,7 +31,7 @@ describe Gitlab::Ci::Config::Loader do describe '#load!' do it 'raises error' do expect { loader.load! }.to raise_error( - Gitlab::Ci::Config::Loader::FormatError, + Gitlab::Config::Loader::FormatError, 'Invalid configuration format' ) end @@ -43,7 +43,7 @@ describe Gitlab::Ci::Config::Loader do describe '#initialize' do it 'raises FormatError' do - expect { loader }.to raise_error(Gitlab::Ci::Config::Loader::FormatError, 'Unknown alias: bad_alias') + expect { loader }.to raise_error(Gitlab::Config::Loader::FormatError, 'Unknown alias: bad_alias') end end end diff --git a/spec/lib/gitlab/correlation_id_spec.rb b/spec/lib/gitlab/correlation_id_spec.rb new file mode 100644 index 00000000000..584d1f48386 --- /dev/null +++ b/spec/lib/gitlab/correlation_id_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +describe Gitlab::CorrelationId do + describe '.use_id' do + it 'yields when executed' do + expect { |blk| described_class.use_id('id', &blk) }.to yield_control + end + + it 'stacks correlation ids' do + described_class.use_id('id1') do + described_class.use_id('id2') do |current_id| + expect(current_id).to eq('id2') + end + end + end + + it 'for missing correlation id it generates random one' do + described_class.use_id('id1') do + described_class.use_id(nil) do |current_id| + expect(current_id).not_to be_empty + expect(current_id).not_to eq('id1') + end + end + end + end + + describe '.current_id' do + subject { described_class.current_id } + + it 'returns last correlation id' do + described_class.use_id('id1') do + described_class.use_id('id2') do + is_expected.to eq('id2') + end + end + end + end + + describe '.current_or_new_id' do + subject { described_class.current_or_new_id } + + context 'when correlation id is set' do + it 'returns last correlation id' do + described_class.use_id('id1') do + is_expected.to eq('id1') + end + end + end + + context 'when correlation id is missing' do + it 'returns a new correlation id' do + expect(described_class).to receive(:new_id) + .and_call_original + + is_expected.not_to be_empty + end + end + end + + describe '.ids' do + subject { described_class.send(:ids) } + + it 'returns empty list if not correlation is used' do + is_expected.to be_empty + end + + it 'returns list if correlation ids are used' do + described_class.use_id('id1') do + described_class.use_id('id2') do + is_expected.to eq(%w(id1 id2)) + end + end + end + end +end diff --git a/spec/lib/gitlab/crypto_helper_spec.rb b/spec/lib/gitlab/crypto_helper_spec.rb new file mode 100644 index 00000000000..05cc6cf15de --- /dev/null +++ b/spec/lib/gitlab/crypto_helper_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Gitlab::CryptoHelper do + describe '.sha256' do + it 'generates SHA256 digest Base46 encoded' do + digest = described_class.sha256('some-value') + + expect(digest).to match %r{\A[A-Za-z0-9+/=]+\z} + expect(digest).to eq digest.strip + end + end + + describe '.aes256_gcm_encrypt' do + it 'is Base64 encoded string without new line character' do + encrypted = described_class.aes256_gcm_encrypt('some-value') + + expect(encrypted).to match %r{\A[A-Za-z0-9+/=]+\z} + expect(encrypted).not_to include "\n" + end + end + + describe '.aes256_gcm_decrypt' do + let(:encrypted) { described_class.aes256_gcm_encrypt('some-value') } + + it 'correctly decrypts encrypted string' do + decrypted = described_class.aes256_gcm_decrypt(encrypted) + + expect(decrypted).to eq 'some-value' + end + + it 'decrypts a value when it ends with a new line character' do + decrypted = described_class.aes256_gcm_decrypt(encrypted + "\n") + + expect(decrypted).to eq 'some-value' + end + end +end diff --git a/spec/lib/gitlab/database/count/exact_count_strategy_spec.rb b/spec/lib/gitlab/database/count/exact_count_strategy_spec.rb new file mode 100644 index 00000000000..3991c737a26 --- /dev/null +++ b/spec/lib/gitlab/database/count/exact_count_strategy_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Gitlab::Database::Count::ExactCountStrategy do + before do + create_list(:project, 3) + create(:identity) + end + + let(:models) { [Project, Identity] } + + subject { described_class.new(models).count } + + describe '#count' do + it 'counts all models' do + expect(models).to all(receive(:count).and_call_original) + + expect(subject).to eq({ Project => 3, Identity => 1 }) + end + + it 'returns default value if count times out' do + allow(models.first).to receive(:count).and_raise(ActiveRecord::StatementInvalid.new('')) + + expect(subject).to eq({}) + end + end + + describe '.enabled?' do + it 'is enabled for PostgreSQL' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + + expect(described_class.enabled?).to be_truthy + end + + it 'is enabled for MySQL' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + + expect(described_class.enabled?).to be_truthy + end + end +end diff --git a/spec/lib/gitlab/database/count/reltuples_count_strategy_spec.rb b/spec/lib/gitlab/database/count/reltuples_count_strategy_spec.rb new file mode 100644 index 00000000000..b44e8c5a110 --- /dev/null +++ b/spec/lib/gitlab/database/count/reltuples_count_strategy_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Gitlab::Database::Count::ReltuplesCountStrategy do + before do + create_list(:project, 3) + create(:identity) + end + + let(:models) { [Project, Identity] } + subject { described_class.new(models).count } + + describe '#count', :postgresql do + context 'when reltuples is up to date' do + before do + ActiveRecord::Base.connection.execute('ANALYZE projects') + ActiveRecord::Base.connection.execute('ANALYZE identities') + end + + it 'uses statistics to do the count' do + models.each { |model| expect(model).not_to receive(:count) } + + expect(subject).to eq({ Project => 3, Identity => 1 }) + end + end + + context 'insufficient permissions' do + it 'returns an empty hash' do + allow(ActiveRecord::Base).to receive(:transaction).and_raise(PG::InsufficientPrivilege) + + expect(subject).to eq({}) + end + end + end + + describe '.enabled?' do + it 'is enabled for PostgreSQL' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + + expect(described_class.enabled?).to be_truthy + end + + it 'is disabled for MySQL' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + + expect(described_class.enabled?).to be_falsey + end + end +end diff --git a/spec/lib/gitlab/database/count/tablesample_count_strategy_spec.rb b/spec/lib/gitlab/database/count/tablesample_count_strategy_spec.rb new file mode 100644 index 00000000000..203f9344a41 --- /dev/null +++ b/spec/lib/gitlab/database/count/tablesample_count_strategy_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe Gitlab::Database::Count::TablesampleCountStrategy do + before do + create_list(:project, 3) + create(:identity) + end + + let(:models) { [Project, Identity] } + let(:strategy) { described_class.new(models) } + + subject { strategy.count } + + describe '#count', :postgresql do + let(:estimates) { { Project => threshold + 1, Identity => threshold - 1 } } + let(:threshold) { Gitlab::Database::Count::TablesampleCountStrategy::EXACT_COUNT_THRESHOLD } + + before do + allow(strategy).to receive(:size_estimates).with(check_statistics: false).and_return(estimates) + end + + context 'for tables with an estimated small size' do + it 'performs an exact count' do + expect(Identity).to receive(:count).and_call_original + + expect(subject).to include({ Identity => 1 }) + end + end + + context 'for tables with an estimated large size' do + it 'performs a tablesample count' do + expect(Project).not_to receive(:count) + + result = subject + expect(result[Project]).to eq(3) + end + end + + context 'insufficient permissions' do + it 'returns an empty hash' do + allow(strategy).to receive(:size_estimates).and_raise(PG::InsufficientPrivilege) + + expect(subject).to eq({}) + end + end + end + + describe '.enabled?' do + before do + stub_feature_flags(tablesample_counts: true) + end + + it 'is enabled for PostgreSQL' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + + expect(described_class.enabled?).to be_truthy + end + + it 'is disabled for MySQL' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + + expect(described_class.enabled?).to be_falsey + end + end +end diff --git a/spec/lib/gitlab/database/count_spec.rb b/spec/lib/gitlab/database/count_spec.rb index 407d9470785..1d096b8fa7c 100644 --- a/spec/lib/gitlab/database/count_spec.rb +++ b/spec/lib/gitlab/database/count_spec.rb @@ -8,63 +8,51 @@ describe Gitlab::Database::Count do let(:models) { [Project, Identity] } - describe '.approximate_counts' do - context 'with MySQL' do - context 'when reltuples have not been updated' do - it 'counts all models the normal way' do - expect(Gitlab::Database).to receive(:postgresql?).and_return(false) + context '.approximate_counts' do + context 'selecting strategies' do + let(:strategies) { [double('s1', enabled?: true), double('s2', enabled?: false)] } - expect(Project).to receive(:count).and_call_original - expect(Identity).to receive(:count).and_call_original + it 'uses only enabled strategies' do + expect(strategies[0]).to receive(:new).and_return(double('strategy1', count: {})) + expect(strategies[1]).not_to receive(:new) - expect(described_class.approximate_counts(models)).to eq({ Project => 3, Identity => 1 }) - end + described_class.approximate_counts(models, strategies: strategies) end end - context 'with PostgreSQL', :postgresql do - describe 'when reltuples have not been updated' do - it 'counts all models the normal way' do - expect(described_class).to receive(:reltuples_from_recently_updated).with(%w(projects identities)).and_return({}) + context 'fallbacks' do + subject { described_class.approximate_counts(models, strategies: strategies) } - expect(Project).to receive(:count).and_call_original - expect(Identity).to receive(:count).and_call_original - expect(described_class.approximate_counts(models)).to eq({ Project => 3, Identity => 1 }) - end + let(:strategies) do + [ + double('s1', enabled?: true, new: first_strategy), + double('s2', enabled?: true, new: second_strategy) + ] end - describe 'no permission' do - it 'falls back to standard query' do - allow(described_class).to receive(:postgresql_estimate_query).and_raise(PG::InsufficientPrivilege) + let(:first_strategy) { double('first strategy', count: {}) } + let(:second_strategy) { double('second strategy', count: {}) } - expect(Project).to receive(:count).and_call_original - expect(Identity).to receive(:count).and_call_original - expect(described_class.approximate_counts(models)).to eq({ Project => 3, Identity => 1 }) - end + it 'gets results from first strategy' do + expect(strategies[0]).to receive(:new).with(models).and_return(first_strategy) + expect(first_strategy).to receive(:count) + + subject end - describe 'when some reltuples have been updated' do - it 'counts projects in the fast way' do - expect(described_class).to receive(:reltuples_from_recently_updated).with(%w(projects identities)).and_return({ 'projects' => 3 }) + it 'gets more results from second strategy if some counts are missing' do + expect(first_strategy).to receive(:count).and_return({ Project => 3 }) + expect(strategies[1]).to receive(:new).with([Identity]).and_return(second_strategy) + expect(second_strategy).to receive(:count).and_return({ Identity => 1 }) - expect(Project).not_to receive(:count).and_call_original - expect(Identity).to receive(:count).and_call_original - expect(described_class.approximate_counts(models)).to eq({ Project => 3, Identity => 1 }) - end + expect(subject).to eq({ Project => 3, Identity => 1 }) end - describe 'when all reltuples have been updated' do - before do - ActiveRecord::Base.connection.execute('ANALYZE projects') - ActiveRecord::Base.connection.execute('ANALYZE identities') - end - - it 'counts models with the standard way' do - expect(Project).not_to receive(:count) - expect(Identity).not_to receive(:count) + it 'does not get more results as soon as all counts are present' do + expect(first_strategy).to receive(:count).and_return({ Project => 3, Identity => 1 }) + expect(strategies[1]).not_to receive(:new) - expect(described_class.approximate_counts(models)).to eq({ Project => 3, Identity => 1 }) - end + subject end end end diff --git a/spec/lib/gitlab/diff/file_collection/commit_spec.rb b/spec/lib/gitlab/diff/file_collection/commit_spec.rb index 6d1b66deb6a..34ed22b8941 100644 --- a/spec/lib/gitlab/diff/file_collection/commit_spec.rb +++ b/spec/lib/gitlab/diff/file_collection/commit_spec.rb @@ -12,4 +12,8 @@ describe Gitlab::Diff::FileCollection::Commit do let(:diffable) { project.commit } let(:stub_path) { 'bar/branch-test.txt' } end + + it_behaves_like 'unfoldable diff' do + let(:diffable) { project.commit } + end end diff --git a/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb b/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb index fbcf515281e..256166dbad3 100644 --- a/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb +++ b/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb @@ -2,22 +2,29 @@ require 'spec_helper' describe Gitlab::Diff::FileCollection::MergeRequestDiff do let(:merge_request) { create(:merge_request) } - let(:diff_files) { described_class.new(merge_request.merge_request_diff, diff_options: nil).diff_files } + let(:subject) { described_class.new(merge_request.merge_request_diff, diff_options: nil) } + let(:diff_files) { subject.diff_files } - it 'does not highlight binary files' do - allow_any_instance_of(Gitlab::Diff::File).to receive(:text?).and_return(false) + describe '#diff_files' do + it 'does not highlight binary files' do + allow_any_instance_of(Gitlab::Diff::File).to receive(:text?).and_return(false) - expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines) + expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines) - diff_files - end + diff_files + end + + it 'does not highlight files marked as undiffable in .gitattributes' do + allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(false) - it 'does not highlight files marked as undiffable in .gitattributes' do - allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(false) + expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines) - expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines) + diff_files + end + end - diff_files + it_behaves_like 'unfoldable diff' do + let(:diffable) { merge_request.merge_request_diff } end it 'it uses a different cache key if diff line keys change' do diff --git a/spec/lib/gitlab/git/repository_cleaner_spec.rb b/spec/lib/gitlab/git/repository_cleaner_spec.rb new file mode 100644 index 00000000000..a9d9e67ef94 --- /dev/null +++ b/spec/lib/gitlab/git/repository_cleaner_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe Gitlab::Git::RepositoryCleaner do + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:head_sha) { repository.head_commit.id } + + let(:object_map) { StringIO.new("#{head_sha} #{'0' * 40}") } + + subject(:cleaner) { described_class.new(repository.raw) } + + describe '#apply_bfg_object_map' do + it 'removes internal references pointing at SHAs in the object map' do + # Create some refs we expect to be removed + repository.keep_around(head_sha) + repository.create_ref(head_sha, 'refs/environments/1') + repository.create_ref(head_sha, 'refs/merge-requests/1') + repository.create_ref(head_sha, 'refs/heads/_keep') + repository.create_ref(head_sha, 'refs/tags/_keep') + + cleaner.apply_bfg_object_map(object_map) + + aggregate_failures do + expect(repository.kept_around?(head_sha)).to be_falsy + expect(repository.ref_exists?('refs/environments/1')).to be_falsy + expect(repository.ref_exists?('refs/merge-requests/1')).to be_falsy + expect(repository.ref_exists?('refs/heads/_keep')).to be_truthy + expect(repository.ref_exists?('refs/tags/_keep')).to be_truthy + end + end + end +end diff --git a/spec/lib/gitlab/gitaly_client/cleanup_service_spec.rb b/spec/lib/gitlab/gitaly_client/cleanup_service_spec.rb new file mode 100644 index 00000000000..369deff732a --- /dev/null +++ b/spec/lib/gitlab/gitaly_client/cleanup_service_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Gitlab::GitalyClient::CleanupService do + let(:project) { create(:project) } + let(:storage_name) { project.repository_storage } + let(:relative_path) { project.disk_path + '.git' } + let(:client) { described_class.new(project.repository) } + + describe '#apply_bfg_object_map' do + it 'sends an apply_bfg_object_map message' do + expect_any_instance_of(Gitaly::CleanupService::Stub) + .to receive(:apply_bfg_object_map) + .with(kind_of(Enumerator), kind_of(Hash)) + .and_return(double) + + client.apply_bfg_object_map(StringIO.new) + end + end +end 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 d8f01dcb76b..77f5b2ffa37 100644 --- a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb @@ -218,7 +218,7 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do describe '#fail_import' do it 'marks the import as failed' do - expect(project).to receive(:mark_import_as_failed).with('foo') + expect(project.import_state).to receive(:mark_as_failed).with('foo') expect(importer.fail_import('foo')).to eq(false) end diff --git a/spec/lib/gitlab/github_import/parallel_importer_spec.rb b/spec/lib/gitlab/github_import/parallel_importer_spec.rb index 20b48c1de68..f5df38c9aaf 100644 --- a/spec/lib/gitlab/github_import/parallel_importer_spec.rb +++ b/spec/lib/gitlab/github_import/parallel_importer_spec.rb @@ -36,7 +36,7 @@ describe Gitlab::GithubImport::ParallelImporter do it 'updates the import JID of the project' do importer.execute - expect(project.reload.import_jid).to eq("github-importer/#{project.id}") + expect(project.import_state.reload.jid).to eq("github-importer/#{project.id}") end end end diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index 8c6d673391b..8229f0eb794 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -26,6 +26,28 @@ describe Gitlab::Gpg::Commit do end end + context 'invalid signature' do + let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first } + + let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) } + + before do + allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily) + .with(Gitlab::Git::Repository, commit_sha) + .and_return( + [ + # Corrupt the key + GpgHelpers::User1.signed_commit_signature.tr('=', 'a'), + GpgHelpers::User1.signed_commit_base_data + ] + ) + end + + it 'returns nil' do + expect(described_class.new(commit).signature).to be_nil + end + end + context 'known key' do context 'user matches the key uid' do context 'user email matches the email committer' do diff --git a/spec/lib/gitlab/group_hierarchy_spec.rb b/spec/lib/gitlab/group_hierarchy_spec.rb index 30686634af4..f3de7adcec7 100644 --- a/spec/lib/gitlab/group_hierarchy_spec.rb +++ b/spec/lib/gitlab/group_hierarchy_spec.rb @@ -34,6 +34,28 @@ describe Gitlab::GroupHierarchy, :postgresql do expect { relation.update_all(share_with_group_lock: false) } .to raise_error(ActiveRecord::ReadOnlyRecord) end + + describe 'hierarchy_order option' do + let(:relation) do + described_class.new(Group.where(id: child2.id)).base_and_ancestors(hierarchy_order: hierarchy_order) + end + + context ':asc' do + let(:hierarchy_order) { :asc } + + it 'orders by child to parent' do + expect(relation).to eq([child2, child1, parent]) + end + end + + context ':desc' do + let(:hierarchy_order) { :desc } + + it 'orders by parent to child' do + expect(relation).to eq([parent, child1, child2]) + end + end + end end describe '#base_and_descendants' do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 1d184375a52..bae5b21c26f 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -94,6 +94,7 @@ merge_requests: - timelogs - head_pipeline - latest_merge_request_diff +- merge_request_pipelines merge_request_diff: - merge_request - merge_request_diff_commits @@ -102,7 +103,7 @@ merge_request_diff_commits: - merge_request_diff merge_request_diff_files: - merge_request_diff -pipelines: +ci_pipelines: - project - user - stages @@ -121,6 +122,9 @@ pipelines: - artifacts - pipeline_schedule - merge_requests +- merge_request +- deployments +- environments pipeline_variables: - pipeline stages: @@ -245,6 +249,7 @@ project: - protected_branches - protected_tags - project_members +- project_repository - users - requesters - deploy_keys_projects @@ -260,7 +265,8 @@ project: - notification_settings - import_data - commit_statuses -- pipelines +- ci_pipelines +- all_pipelines - stages - builds - runner_projects @@ -281,6 +287,7 @@ project: - statistics - container_repositories - uploads +- file_uploads - import_state - members_and_requesters - build_trace_section_names @@ -301,6 +308,7 @@ project: - import_export_upload - repository_languages - pool_repository +- kubernetes_namespaces award_emoji: - awardable - user diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 7171e12a849..242c16c4bdc 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -197,9 +197,9 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end it 'has the correct number of pipelines and statuses' do - expect(@project.pipelines.size).to eq(5) + expect(@project.ci_pipelines.size).to eq(5) - @project.pipelines.zip([2, 2, 2, 2, 2]) + @project.ci_pipelines.zip([2, 2, 2, 2, 2]) .each do |(pipeline, expected_status_size)| expect(pipeline.statuses.size).to eq(expected_status_size) end diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 5dc372263ad..46fdfba953b 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -119,16 +119,16 @@ describe Gitlab::ImportExport::ProjectTreeSaver do end it 'has pipeline stages' do - expect(saved_project_json.dig('pipelines', 0, 'stages')).not_to be_empty + expect(saved_project_json.dig('ci_pipelines', 0, 'stages')).not_to be_empty end it 'has pipeline statuses' do - expect(saved_project_json.dig('pipelines', 0, 'stages', 0, 'statuses')).not_to be_empty + expect(saved_project_json.dig('ci_pipelines', 0, 'stages', 0, 'statuses')).not_to be_empty end it 'has pipeline builds' do builds_count = saved_project_json - .dig('pipelines', 0, 'stages', 0, 'statuses') + .dig('ci_pipelines', 0, 'stages', 0, 'statuses') .count { |hash| hash['type'] == 'Ci::Build' } expect(builds_count).to eq(1) @@ -142,11 +142,11 @@ describe Gitlab::ImportExport::ProjectTreeSaver do end it 'has pipeline commits' do - expect(saved_project_json['pipelines']).not_to be_empty + expect(saved_project_json['ci_pipelines']).not_to be_empty end it 'has ci pipeline notes' do - expect(saved_project_json['pipelines'].first['notes']).not_to be_empty + expect(saved_project_json['ci_pipelines'].first['notes']).not_to be_empty end it 'has labels with no associations' do diff --git a/spec/lib/gitlab/import_export/relation_rename_service_spec.rb b/spec/lib/gitlab/import_export/relation_rename_service_spec.rb new file mode 100644 index 00000000000..a20a844a492 --- /dev/null +++ b/spec/lib/gitlab/import_export/relation_rename_service_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::ImportExport::RelationRenameService do + let(:renames) do + { + 'example_relation1' => 'new_example_relation1', + 'example_relation2' => 'new_example_relation2' + } + end + + let(:user) { create(:admin) } + let(:group) { create(:group, :nested) } + let!(:project) { create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') } + let(:shared) { project.import_export_shared } + + before do + stub_const("#{described_class}::RENAMES", renames) + end + + context 'when importing' do + let(:project_tree_restorer) { Gitlab::ImportExport::ProjectTreeRestorer.new(user: user, shared: shared, project: project) } + let(:import_path) { 'spec/lib/gitlab/import_export' } + let(:file_content) { IO.read("#{import_path}/project.json") } + let!(:json_file) { ActiveSupport::JSON.decode(file_content) } + let(:tree_hash) { project_tree_restorer.instance_variable_get(:@tree_hash) } + + before do + allow(shared).to receive(:export_path).and_return(import_path) + allow(ActiveSupport::JSON).to receive(:decode).with(file_content).and_return(json_file) + end + + context 'when the file has only old relationship names' do + # Configuring the json as an old version exported file, with only + # the previous association with the old name + before do + renames.each do |old_name, _| + json_file[old_name.to_s] = [] + end + end + + it 'renames old relationships to the new name' do + expect(json_file.keys).to include(*renames.keys) + + project_tree_restorer.restore + + expect(json_file.keys).to include(*renames.values) + expect(json_file.keys).not_to include(*renames.keys) + end + end + + context 'when the file has both the old and new relationships' do + # Configuring the json as the new version exported file, with both + # the old association name and the new one + before do + renames.each do |old_name, new_name| + json_file[old_name.to_s] = [1] + json_file[new_name.to_s] = [2] + end + end + + it 'uses the new relationships and removes the old ones from the hash' do + expect(json_file.keys).to include(*renames.keys) + + project_tree_restorer.restore + + expect(json_file.keys).to include(*renames.values) + expect(json_file.values_at(*renames.values).flatten.uniq.first).to eq 2 + expect(json_file.keys).not_to include(*renames.keys) + end + end + + context 'when the file has only new relationship names' do + # Configuring the json as the future version exported file, with only + # the new association name + before do + renames.each do |_, new_name| + json_file[new_name.to_s] = [] + end + end + + it 'uses the new relationships' do + expect(json_file.keys).not_to include(*renames.keys) + + project_tree_restorer.restore + + expect(json_file.keys).to include(*renames.values) + end + end + end + + context 'when exporting' do + let(:project_tree_saver) { Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: user, shared: shared) } + let(:project_tree) { project_tree_saver.send(:project_json) } + + it 'adds old relationships to the exported file' do + project_tree.merge!(renames.values.map { |new_name| [new_name, []] }.to_h) + + allow(project_tree_saver).to receive(:save) do |arg| + project_tree_saver.send(:project_json_tree) + end + + result = project_tree_saver.save + + saved_data = ActiveSupport::JSON.decode(result) + + expect(saved_data.keys).to include(*(renames.keys + renames.values)) + end + end +end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index f7935149b23..d3bfde181bc 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -243,6 +243,7 @@ Ci::Pipeline: - failure_reason - protected - iid +- merge_request_id Ci::Stage: - id - name diff --git a/spec/lib/gitlab/json_logger_spec.rb b/spec/lib/gitlab/json_logger_spec.rb index 0a62785f880..cff7dd58c8c 100644 --- a/spec/lib/gitlab/json_logger_spec.rb +++ b/spec/lib/gitlab/json_logger_spec.rb @@ -7,6 +7,10 @@ describe Gitlab::JsonLogger do let(:now) { Time.now } describe '#format_message' do + before do + allow(Gitlab::CorrelationId).to receive(:current_id).and_return('new-correlation-id') + end + it 'formats strings' do output = subject.format_message('INFO', now, 'test', 'Hello world') data = JSON.parse(output) @@ -14,6 +18,7 @@ describe Gitlab::JsonLogger do expect(data['severity']).to eq('INFO') expect(data['time']).to eq(now.utc.iso8601(3)) expect(data['message']).to eq('Hello world') + expect(data['correlation_id']).to eq('new-correlation-id') end it 'formats hashes' do @@ -24,6 +29,7 @@ describe Gitlab::JsonLogger do expect(data['time']).to eq(now.utc.iso8601(3)) expect(data['hello']).to eq(1) expect(data['message']).to be_nil + expect(data['correlation_id']).to eq('new-correlation-id') end end end diff --git a/spec/lib/gitlab/kubernetes/kube_client_spec.rb b/spec/lib/gitlab/kubernetes/kube_client_spec.rb index 3979a43216c..8fc85301304 100644 --- a/spec/lib/gitlab/kubernetes/kube_client_spec.rb +++ b/spec/lib/gitlab/kubernetes/kube_client_spec.rb @@ -99,6 +99,7 @@ describe Gitlab::Kubernetes::KubeClient do :create_secret, :create_service_account, :update_config_map, + :update_secret, :update_service_account ].each do |method| describe "##{method}" do @@ -174,6 +175,84 @@ describe Gitlab::Kubernetes::KubeClient do end end + shared_examples 'create_or_update method' do + let(:get_method) { "get_#{resource_type}" } + let(:update_method) { "update_#{resource_type}" } + let(:create_method) { "create_#{resource_type}" } + + context 'resource exists' do + before do + expect(client).to receive(get_method).and_return(resource) + end + + it 'calls the update method' do + expect(client).to receive(update_method).with(resource) + + subject + end + end + + context 'resource does not exist' do + before do + expect(client).to receive(get_method).and_raise(Kubeclient::ResourceNotFoundError.new(404, 'Not found', nil)) + end + + it 'calls the create method' do + expect(client).to receive(create_method).with(resource) + + subject + end + end + end + + describe '#create_or_update_cluster_role_binding' do + let(:resource_type) { 'cluster_role_binding' } + + let(:resource) do + ::Kubeclient::Resource.new(metadata: { name: 'name', namespace: 'namespace' }) + end + + subject { client.create_or_update_cluster_role_binding(resource) } + + it_behaves_like 'create_or_update method' + end + + describe '#create_or_update_role_binding' do + let(:resource_type) { 'role_binding' } + + let(:resource) do + ::Kubeclient::Resource.new(metadata: { name: 'name', namespace: 'namespace' }) + end + + subject { client.create_or_update_role_binding(resource) } + + it_behaves_like 'create_or_update method' + end + + describe '#create_or_update_service_account' do + let(:resource_type) { 'service_account' } + + let(:resource) do + ::Kubeclient::Resource.new(metadata: { name: 'name', namespace: 'namespace' }) + end + + subject { client.create_or_update_service_account(resource) } + + it_behaves_like 'create_or_update method' + end + + describe '#create_or_update_secret' do + let(:resource_type) { 'secret' } + + let(:resource) do + ::Kubeclient::Resource.new(metadata: { name: 'name', namespace: 'namespace' }) + end + + subject { client.create_or_update_secret(resource) } + + it_behaves_like 'create_or_update method' + end + describe 'methods that do not exist on any client' do it 'throws an error' do expect { client.non_existent_method }.to raise_error(NoMethodError) diff --git a/spec/lib/gitlab/kubernetes_spec.rb b/spec/lib/gitlab/kubernetes_spec.rb index 5c03a2ce7d3..f326d57e9c6 100644 --- a/spec/lib/gitlab/kubernetes_spec.rb +++ b/spec/lib/gitlab/kubernetes_spec.rb @@ -48,26 +48,30 @@ describe Gitlab::Kubernetes do end describe '#to_kubeconfig' do + let(:token) { 'TOKEN' } + let(:ca_pem) { 'PEM' } + subject do to_kubeconfig( url: 'https://kube.domain.com', namespace: 'NAMESPACE', - token: 'TOKEN', - ca_pem: ca_pem) + token: token, + ca_pem: ca_pem + ) end - context 'when CA PEM is provided' do - let(:ca_pem) { 'PEM' } - let(:path) { expand_fixture_path('config/kubeconfig.yml') } - - it { is_expected.to eq(YAML.load_file(path)) } - end + it { expect(YAML.safe_load(subject)).to eq(YAML.load_file(expand_fixture_path('config/kubeconfig.yml'))) } context 'when CA PEM is not provided' do let(:ca_pem) { nil } - let(:path) { expand_fixture_path('config/kubeconfig-without-ca.yml') } - it { is_expected.to eq(YAML.load_file(path)) } + it { expect(YAML.safe_load(subject)).to eq(YAML.load_file(expand_fixture_path('config/kubeconfig-without-ca.yml'))) } + end + + context 'when token is not provided' do + let(:token) { nil } + + it { is_expected.to be_nil } end end diff --git a/spec/lib/gitlab/legacy_github_import/importer_spec.rb b/spec/lib/gitlab/legacy_github_import/importer_spec.rb index 20514486727..d2df21d7bb5 100644 --- a/spec/lib/gitlab/legacy_github_import/importer_spec.rb +++ b/spec/lib/gitlab/legacy_github_import/importer_spec.rb @@ -174,7 +174,7 @@ describe Gitlab::LegacyGithubImport::Importer do described_class.new(project).execute - expect(project.import_error).to eq error.to_json + expect(project.import_state.last_error).to eq error.to_json end end diff --git a/spec/lib/gitlab/lfs_token_spec.rb b/spec/lib/gitlab/lfs_token_spec.rb index 3a20dad16d0..77ee30264bf 100644 --- a/spec/lib/gitlab/lfs_token_spec.rb +++ b/spec/lib/gitlab/lfs_token_spec.rb @@ -48,4 +48,59 @@ describe Gitlab::LfsToken do end end end + + describe '#deploy_key_pushable?' do + let(:lfs_token) { described_class.new(actor) } + + context 'when actor is not a DeployKey' do + let(:actor) { create(:user) } + let(:project) { create(:project) } + + it 'returns false' do + expect(lfs_token.deploy_key_pushable?(project)).to be_falsey + end + end + + context 'when actor is a DeployKey' do + let(:deploy_keys_project) { create(:deploy_keys_project, can_push: can_push) } + let(:project) { deploy_keys_project.project } + let(:actor) { deploy_keys_project.deploy_key } + + context 'but the DeployKey cannot push to the project' do + let(:can_push) { false } + + it 'returns false' do + expect(lfs_token.deploy_key_pushable?(project)).to be_falsey + end + end + + context 'and the DeployKey can push to the project' do + let(:can_push) { true } + + it 'returns true' do + expect(lfs_token.deploy_key_pushable?(project)).to be_truthy + end + end + end + end + + describe '#type' do + let(:lfs_token) { described_class.new(actor) } + + context 'when actor is not a User' do + let(:actor) { create(:deploy_key) } + + it 'returns false' do + expect(lfs_token.type).to eq(:lfs_deploy_token) + end + end + + context 'when actor is a User' do + let(:actor) { create(:user) } + + it 'returns false' do + expect(lfs_token.type).to eq(:lfs_token) + end + end + end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 4a0dc3686ec..6831274d37c 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -54,11 +54,18 @@ describe Gitlab::ProjectSearchResults do end it 'finds by name' do - expect(results.map(&:first)).to include(expected_file_by_name) + expect(results.map(&:filename)).to include(expected_file_by_name) + end + + it "loads all blobs for filename matches in single batch" do + expect(Gitlab::Git::Blob).to receive(:batch).once.and_call_original + + expected = project.repository.search_files_by_name(query, 'master') + expect(results.map(&:filename)).to include(*expected) end it 'finds by content' do - blob = results.select { |result| result.first == expected_file_by_content }.flatten.last + blob = results.select { |result| result.filename == expected_file_by_content }.flatten.last expect(blob.filename).to eq(expected_file_by_content) end @@ -122,126 +129,6 @@ describe Gitlab::ProjectSearchResults do let(:blob_type) { 'blobs' } let(:entity) { project } end - - describe 'parsing results' do - let(:results) { project.repository.search_files_by_content('feature', 'master') } - let(:search_result) { results.first } - - subject { described_class.parse_search_result(search_result) } - - it "returns a valid FoundBlob" do - is_expected.to be_an Gitlab::SearchResults::FoundBlob - expect(subject.id).to be_nil - expect(subject.path).to eq('CHANGELOG') - expect(subject.filename).to eq('CHANGELOG') - expect(subject.basename).to eq('CHANGELOG') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(188) - expect(subject.data.lines[2]).to eq(" - Feature: Replace teams with group membership\n") - end - - context 'when the matching filename contains a colon' do - let(:search_result) { "master:testdata/project::function1.yaml\x001\x00---\n" } - - it 'returns a valid FoundBlob' do - expect(subject.filename).to eq('testdata/project::function1.yaml') - expect(subject.basename).to eq('testdata/project::function1') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq("---\n") - end - end - - context 'when the matching content contains a number surrounded by colons' do - let(:search_result) { "master:testdata/foo.txt\x001\x00blah:9:blah" } - - it 'returns a valid FoundBlob' do - expect(subject.filename).to eq('testdata/foo.txt') - expect(subject.basename).to eq('testdata/foo') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq('blah:9:blah') - end - end - - context 'when the matching content contains multiple null bytes' do - let(:search_result) { "master:testdata/foo.txt\x001\x00blah\x001\x00foo" } - - it 'returns a valid FoundBlob' do - expect(subject.filename).to eq('testdata/foo.txt') - expect(subject.basename).to eq('testdata/foo') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq("blah\x001\x00foo") - end - end - - context 'when the search result ends with an empty line' do - let(:results) { project.repository.search_files_by_content('Role models', 'master') } - - it 'returns a valid FoundBlob that ends with an empty line' do - expect(subject.filename).to eq('files/markdown/ruby-style-guide.md') - expect(subject.basename).to eq('files/markdown/ruby-style-guide') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq("# Prelude\n\n> Role models are important. <br/>\n> -- Officer Alex J. Murphy / RoboCop\n\n") - end - end - - context 'when the search returns non-ASCII data' do - context 'with UTF-8' do - let(:results) { project.repository.search_files_by_content('файл', 'master') } - - it 'returns results as UTF-8' do - expect(subject.filename).to eq('encoding/russian.rb') - expect(subject.basename).to eq('encoding/russian') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq("Хороший файл\n") - end - end - - context 'with UTF-8 in the filename' do - let(:results) { project.repository.search_files_by_content('webhook', 'master') } - - it 'returns results as UTF-8' do - expect(subject.filename).to eq('encoding/テスト.txt') - expect(subject.basename).to eq('encoding/テスト') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(3) - expect(subject.data).to include('WebHookの確認') - end - end - - context 'with ISO-8859-1' do - let(:search_result) { "master:encoding/iso8859.txt\x001\x00\xC4\xFC\nmaster:encoding/iso8859.txt\x002\x00\nmaster:encoding/iso8859.txt\x003\x00foo\n".force_encoding(Encoding::ASCII_8BIT) } - - it 'returns results as UTF-8' do - expect(subject.filename).to eq('encoding/iso8859.txt') - expect(subject.basename).to eq('encoding/iso8859') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq("Äü\n\nfoo\n") - end - end - end - - context "when filename has extension" do - let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" } - - it { expect(subject.path).to eq('CONTRIBUTE.md') } - it { expect(subject.filename).to eq('CONTRIBUTE.md') } - it { expect(subject.basename).to eq('CONTRIBUTE') } - end - - context "when file under directory" do - let(:search_result) { "master:a/b/c.md\x005\x00a b c\n" } - - it { expect(subject.path).to eq('a/b/c.md') } - it { expect(subject.filename).to eq('a/b/c.md') } - it { expect(subject.basename).to eq('a/b/c') } - end - end end describe 'wiki search' do diff --git a/spec/lib/gitlab/search/found_blob_spec.rb b/spec/lib/gitlab/search/found_blob_spec.rb new file mode 100644 index 00000000000..74157e5c67c --- /dev/null +++ b/spec/lib/gitlab/search/found_blob_spec.rb @@ -0,0 +1,138 @@ +# coding: utf-8 + +require 'spec_helper' + +describe Gitlab::Search::FoundBlob do + describe 'parsing results' do + let(:project) { create(:project, :public, :repository) } + let(:results) { project.repository.search_files_by_content('feature', 'master') } + let(:search_result) { results.first } + + subject { described_class.new(content_match: search_result, project: project) } + + it "returns a valid FoundBlob" do + is_expected.to be_an described_class + expect(subject.id).to be_nil + expect(subject.path).to eq('CHANGELOG') + expect(subject.filename).to eq('CHANGELOG') + expect(subject.basename).to eq('CHANGELOG') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(188) + expect(subject.data.lines[2]).to eq(" - Feature: Replace teams with group membership\n") + end + + it "doesn't parses content if not needed" do + expect(subject).not_to receive(:parse_search_result) + expect(subject.project_id).to eq(project.id) + expect(subject.binary_filename).to eq('CHANGELOG') + end + + it "parses content only once when needed" do + expect(subject).to receive(:parse_search_result).once.and_call_original + expect(subject.filename).to eq('CHANGELOG') + expect(subject.startline).to eq(188) + end + + context 'when the matching filename contains a colon' do + let(:search_result) { "master:testdata/project::function1.yaml\x001\x00---\n" } + + it 'returns a valid FoundBlob' do + expect(subject.filename).to eq('testdata/project::function1.yaml') + expect(subject.basename).to eq('testdata/project::function1') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("---\n") + end + end + + context 'when the matching content contains a number surrounded by colons' do + let(:search_result) { "master:testdata/foo.txt\x001\x00blah:9:blah" } + + it 'returns a valid FoundBlob' do + expect(subject.filename).to eq('testdata/foo.txt') + expect(subject.basename).to eq('testdata/foo') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq('blah:9:blah') + end + end + + context 'when the matching content contains multiple null bytes' do + let(:search_result) { "master:testdata/foo.txt\x001\x00blah\x001\x00foo" } + + it 'returns a valid FoundBlob' do + expect(subject.filename).to eq('testdata/foo.txt') + expect(subject.basename).to eq('testdata/foo') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("blah\x001\x00foo") + end + end + + context 'when the search result ends with an empty line' do + let(:results) { project.repository.search_files_by_content('Role models', 'master') } + + it 'returns a valid FoundBlob that ends with an empty line' do + expect(subject.filename).to eq('files/markdown/ruby-style-guide.md') + expect(subject.basename).to eq('files/markdown/ruby-style-guide') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("# Prelude\n\n> Role models are important. <br/>\n> -- Officer Alex J. Murphy / RoboCop\n\n") + end + end + + context 'when the search returns non-ASCII data' do + context 'with UTF-8' do + let(:results) { project.repository.search_files_by_content('файл', 'master') } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/russian.rb') + expect(subject.basename).to eq('encoding/russian') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("Хороший файл\n") + end + end + + context 'with UTF-8 in the filename' do + let(:results) { project.repository.search_files_by_content('webhook', 'master') } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/テスト.txt') + expect(subject.basename).to eq('encoding/テスト') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(3) + expect(subject.data).to include('WebHookの確認') + end + end + + context 'with ISO-8859-1' do + let(:search_result) { "master:encoding/iso8859.txt\x001\x00\xC4\xFC\nmaster:encoding/iso8859.txt\x002\x00\nmaster:encoding/iso8859.txt\x003\x00foo\n".force_encoding(Encoding::ASCII_8BIT) } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/iso8859.txt') + expect(subject.basename).to eq('encoding/iso8859') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("Äü\n\nfoo\n") + end + end + end + + context "when filename has extension" do + let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" } + + it { expect(subject.path).to eq('CONTRIBUTE.md') } + it { expect(subject.filename).to eq('CONTRIBUTE.md') } + it { expect(subject.basename).to eq('CONTRIBUTE') } + end + + context "when file under directory" do + let(:search_result) { "master:a/b/c.md\x005\x00a b c\n" } + + it { expect(subject.path).to eq('a/b/c.md') } + it { expect(subject.filename).to eq('a/b/c.md') } + it { expect(subject.basename).to eq('a/b/c') } + end + end +end diff --git a/spec/lib/gitlab/sentry_spec.rb b/spec/lib/gitlab/sentry_spec.rb index d3b41b27b80..1128eaf8560 100644 --- a/spec/lib/gitlab/sentry_spec.rb +++ b/spec/lib/gitlab/sentry_spec.rb @@ -19,14 +19,15 @@ describe Gitlab::Sentry do end it 'raises the exception if it should' do - expect(described_class).to receive(:should_raise?).and_return(true) + expect(described_class).to receive(:should_raise_for_dev?).and_return(true) expect { described_class.track_exception(exception) } .to raise_error(RuntimeError) end context 'when exceptions should not be raised' do before do - allow(described_class).to receive(:should_raise?).and_return(false) + allow(described_class).to receive(:should_raise_for_dev?).and_return(false) + allow(Gitlab::CorrelationId).to receive(:current_id).and_return('cid') end it 'logs the exception with all attributes passed' do @@ -35,8 +36,14 @@ describe Gitlab::Sentry do issue_url: 'http://gitlab.com/gitlab-org/gitlab-ce/issues/1' } + expected_tags = { + correlation_id: 'cid' + } + expect(Raven).to receive(:capture_exception) - .with(exception, extra: a_hash_including(expected_extras)) + .with(exception, + tags: a_hash_including(expected_tags), + extra: a_hash_including(expected_extras)) described_class.track_exception( exception, @@ -58,6 +65,7 @@ describe Gitlab::Sentry do before do allow(described_class).to receive(:enabled?).and_return(true) + allow(Gitlab::CorrelationId).to receive(:current_id).and_return('cid') end it 'calls Raven.capture_exception' do @@ -66,8 +74,14 @@ describe Gitlab::Sentry do issue_url: 'http://gitlab.com/gitlab-org/gitlab-ce/issues/1' } + expected_tags = { + correlation_id: 'cid' + } + expect(Raven).to receive(:capture_exception) - .with(exception, extra: a_hash_including(expected_extras)) + .with(exception, + tags: a_hash_including(expected_tags), + extra: a_hash_including(expected_extras)) described_class.track_acceptable_exception( exception, diff --git a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb index 2421b1e5a1a..f773f370ee2 100644 --- a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb +++ b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb @@ -12,7 +12,8 @@ describe Gitlab::SidekiqLogging::StructuredLogger do "queue_namespace" => "cronjob", "jid" => "da883554ee4fe414012f5f42", "created_at" => timestamp.to_f, - "enqueued_at" => timestamp.to_f + "enqueued_at" => timestamp.to_f, + "correlation_id" => 'cid' } end let(:logger) { double() } diff --git a/spec/lib/gitlab/sidekiq_middleware/correlation_injector_spec.rb b/spec/lib/gitlab/sidekiq_middleware/correlation_injector_spec.rb new file mode 100644 index 00000000000..a138ad7c910 --- /dev/null +++ b/spec/lib/gitlab/sidekiq_middleware/correlation_injector_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::SidekiqMiddleware::CorrelationInjector do + class TestWorker + include ApplicationWorker + end + + before do |example| + Sidekiq.client_middleware do |chain| + chain.add described_class + end + end + + after do |example| + Sidekiq.client_middleware do |chain| + chain.remove described_class + end + + Sidekiq::Queues.clear_all + end + + around do |example| + Sidekiq::Testing.fake! do + example.run + end + end + + it 'injects into payload the correlation id' do + expect_any_instance_of(described_class).to receive(:call).and_call_original + + Gitlab::CorrelationId.use_id('new-correlation-id') do + TestWorker.perform_async(1234) + end + + expected_job_params = { + "class" => "TestWorker", + "args" => [1234], + "correlation_id" => "new-correlation-id" + } + + expect(Sidekiq::Queues.jobs_by_worker).to a_hash_including( + "TestWorker" => a_collection_containing_exactly( + a_hash_including(expected_job_params))) + end +end diff --git a/spec/lib/gitlab/sidekiq_middleware/correlation_logger_spec.rb b/spec/lib/gitlab/sidekiq_middleware/correlation_logger_spec.rb new file mode 100644 index 00000000000..94ae4ffa184 --- /dev/null +++ b/spec/lib/gitlab/sidekiq_middleware/correlation_logger_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::SidekiqMiddleware::CorrelationLogger do + class TestWorker + include ApplicationWorker + end + + before do |example| + Sidekiq::Testing.server_middleware do |chain| + chain.add described_class + end + end + + after do |example| + Sidekiq::Testing.server_middleware do |chain| + chain.remove described_class + end + end + + it 'injects into payload the correlation id' do + expect_any_instance_of(described_class).to receive(:call).and_call_original + + expect_any_instance_of(TestWorker).to receive(:perform).with(1234) do + expect(Gitlab::CorrelationId.current_id).to eq('new-correlation-id') + end + + Sidekiq::Client.push( + 'queue' => 'test', + 'class' => TestWorker, + 'args' => [1234], + 'correlation_id' => 'new-correlation-id') + end +end diff --git a/spec/lib/gitlab/template/finders/global_template_finder_spec.rb b/spec/lib/gitlab/template/finders/global_template_finder_spec.rb new file mode 100644 index 00000000000..c7f58fbd2a5 --- /dev/null +++ b/spec/lib/gitlab/template/finders/global_template_finder_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Gitlab::Template::Finders::GlobalTemplateFinder do + let(:base_dir) { Dir.mktmpdir } + + def create_template!(name_with_category) + full_path = File.join(base_dir, name_with_category) + FileUtils.mkdir_p(File.dirname(full_path)) + FileUtils.touch(full_path) + end + + after do + FileUtils.rm_rf(base_dir) + end + + subject(:finder) { described_class.new(base_dir, '', 'Foo' => '', 'Bar' => 'bar') } + + describe '.find' do + it 'finds a template in the Foo category' do + create_template!('test-template') + + expect(finder.find('test-template')).to be_present + end + + it 'finds a template in the Bar category' do + create_template!('bar/test-template') + + expect(finder.find('test-template')).to be_present + end + + it 'does not permit path traversal requests' do + expect { finder.find('../foo') }.to raise_error(/Invalid path/) + end + end +end diff --git a/spec/lib/gitlab/template/finders/repo_template_finders_spec.rb b/spec/lib/gitlab/template/finders/repo_template_finders_spec.rb index 2eabccd5dff..e329d55d837 100644 --- a/spec/lib/gitlab/template/finders/repo_template_finders_spec.rb +++ b/spec/lib/gitlab/template/finders/repo_template_finders_spec.rb @@ -25,6 +25,10 @@ describe Gitlab::Template::Finders::RepoTemplateFinder do expect(result).to eq('files/html/500.html') end + + it 'does not permit path traversal requests' do + expect { finder.find('../foo') }.to raise_error(/Invalid path/) + end end describe '#list_files_for' do diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb index 8df0facdab3..39e0a17a307 100644 --- a/spec/lib/gitlab/url_blocker_spec.rb +++ b/spec/lib/gitlab/url_blocker_spec.rb @@ -10,8 +10,8 @@ describe Gitlab::UrlBlocker do expect(described_class.blocked_url?(import_url)).to be false end - it 'allows imports from configured SSH host and port' do - import_url = "http://#{Gitlab.config.gitlab_shell.ssh_host}:#{Gitlab.config.gitlab_shell.ssh_port}/t.git" + it 'allows mirroring from configured SSH host and port' do + import_url = "ssh://#{Gitlab.config.gitlab_shell.ssh_host}:#{Gitlab.config.gitlab_shell.ssh_port}/t.git" expect(described_class.blocked_url?(import_url)).to be false end @@ -29,24 +29,46 @@ describe Gitlab::UrlBlocker do expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git', protocols: ['http'])).to be true end + it 'returns true for bad protocol on configured web/SSH host and ports' do + web_url = "javascript://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/t.git%0aalert(1)" + expect(described_class.blocked_url?(web_url)).to be true + + ssh_url = "javascript://#{Gitlab.config.gitlab_shell.ssh_host}:#{Gitlab.config.gitlab_shell.ssh_port}/t.git%0aalert(1)" + expect(described_class.blocked_url?(ssh_url)).to be true + end + it 'returns true for localhost IPs' do + expect(described_class.blocked_url?('https://[0:0:0:0:0:0:0:0]/foo/foo.git')).to be true expect(described_class.blocked_url?('https://0.0.0.0/foo/foo.git')).to be true - expect(described_class.blocked_url?('https://[::1]/foo/foo.git')).to be true - expect(described_class.blocked_url?('https://127.0.0.1/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://[::]/foo/foo.git')).to be true end it 'returns true for loopback IP' do expect(described_class.blocked_url?('https://127.0.0.2/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://127.0.0.1/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://[::1]/foo/foo.git')).to be true end it 'returns true for alternative version of 127.0.0.1 (0177.1)' do expect(described_class.blocked_url?('https://0177.1:65535/foo/foo.git')).to be true end + it 'returns true for alternative version of 127.0.0.1 (017700000001)' do + expect(described_class.blocked_url?('https://017700000001:65535/foo/foo.git')).to be true + end + it 'returns true for alternative version of 127.0.0.1 (0x7f.1)' do expect(described_class.blocked_url?('https://0x7f.1:65535/foo/foo.git')).to be true end + it 'returns true for alternative version of 127.0.0.1 (0x7f.0.0.1)' do + expect(described_class.blocked_url?('https://0x7f.0.0.1:65535/foo/foo.git')).to be true + end + + it 'returns true for alternative version of 127.0.0.1 (0x7f000001)' do + expect(described_class.blocked_url?('https://0x7f000001:65535/foo/foo.git')).to be true + end + it 'returns true for alternative version of 127.0.0.1 (2130706433)' do expect(described_class.blocked_url?('https://2130706433:65535/foo/foo.git')).to be true end @@ -55,6 +77,27 @@ describe Gitlab::UrlBlocker do expect(described_class.blocked_url?('https://127.000.000.001:65535/foo/foo.git')).to be true end + it 'returns true for alternative version of 127.0.0.1 (127.0.1)' do + expect(described_class.blocked_url?('https://127.0.1:65535/foo/foo.git')).to be true + end + + context 'with ipv6 mapped address' do + it 'returns true for localhost IPs' do + expect(described_class.blocked_url?('https://[0:0:0:0:0:ffff:0.0.0.0]/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://[::ffff:0.0.0.0]/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://[::ffff:0:0]/foo/foo.git')).to be true + end + + it 'returns true for loopback IPs' do + expect(described_class.blocked_url?('https://[0:0:0:0:0:ffff:127.0.0.1]/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://[::ffff:127.0.0.1]/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://[::ffff:7f00:1]/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://[0:0:0:0:0:ffff:127.0.0.2]/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://[::ffff:127.0.0.2]/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://[::ffff:7f00:2]/foo/foo.git')).to be true + end + end + it 'returns true for a non-alphanumeric hostname' do stub_resolv @@ -78,7 +121,22 @@ describe Gitlab::UrlBlocker do end context 'when allow_local_network is' do - let(:local_ips) { ['192.168.1.2', '10.0.0.2', '172.16.0.2'] } + let(:local_ips) do + [ + '192.168.1.2', + '[0:0:0:0:0:ffff:192.168.1.2]', + '[::ffff:c0a8:102]', + '10.0.0.2', + '[0:0:0:0:0:ffff:10.0.0.2]', + '[::ffff:a00:2]', + '172.16.0.2', + '[0:0:0:0:0:ffff:172.16.0.2]', + '[::ffff:ac10:20]', + '[feef::1]', + '[fee2::]', + '[fc00:bf8b:e62c:abcd:abcd:aaaa:aaaa:aaaa]' + ] + end let(:fake_domain) { 'www.fakedomain.fake' } context 'true (default)' do @@ -109,10 +167,14 @@ describe Gitlab::UrlBlocker do expect(described_class).not_to be_blocked_url('http://169.254.168.100') end - # This is blocked due to the hostname check: https://gitlab.com/gitlab-org/gitlab-ce/issues/50227 - it 'blocks IPv6 link-local endpoints' do - expect(described_class).to be_blocked_url('http://[::ffff:169.254.169.254]') - expect(described_class).to be_blocked_url('http://[::ffff:169.254.168.100]') + it 'allows IPv6 link-local endpoints' do + expect(described_class).not_to be_blocked_url('http://[0:0:0:0:0:ffff:169.254.169.254]') + expect(described_class).not_to be_blocked_url('http://[::ffff:169.254.169.254]') + expect(described_class).not_to be_blocked_url('http://[::ffff:a9fe:a9fe]') + expect(described_class).not_to be_blocked_url('http://[0:0:0:0:0:ffff:169.254.168.100]') + expect(described_class).not_to be_blocked_url('http://[::ffff:169.254.168.100]') + expect(described_class).not_to be_blocked_url('http://[::ffff:a9fe:a864]') + expect(described_class).not_to be_blocked_url('http://[fe80::c800:eff:fe74:8]') end end @@ -135,14 +197,20 @@ describe Gitlab::UrlBlocker do end it 'blocks IPv6 link-local endpoints' do + expect(described_class).to be_blocked_url('http://[0:0:0:0:0:ffff:169.254.169.254]', allow_local_network: false) expect(described_class).to be_blocked_url('http://[::ffff:169.254.169.254]', allow_local_network: false) + expect(described_class).to be_blocked_url('http://[::ffff:a9fe:a9fe]', allow_local_network: false) + expect(described_class).to be_blocked_url('http://[0:0:0:0:0:ffff:169.254.168.100]', allow_local_network: false) expect(described_class).to be_blocked_url('http://[::ffff:169.254.168.100]', allow_local_network: false) - expect(described_class).to be_blocked_url('http://[FE80::C800:EFF:FE74:8]', allow_local_network: false) + expect(described_class).to be_blocked_url('http://[::ffff:a9fe:a864]', allow_local_network: false) + expect(described_class).to be_blocked_url('http://[fe80::c800:eff:fe74:8]', allow_local_network: false) end end def stub_domain_resolv(domain, ip) - allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([double(ip_address: ip, ipv4_private?: true, ipv6_link_local?: false, ipv4_loopback?: false, ipv6_loopback?: false)]) + address = double(ip_address: ip, ipv4_private?: true, ipv6_link_local?: false, ipv4_loopback?: false, ipv6_loopback?: false) + allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([address]) + allow(address).to receive(:ipv6_v4mapped?).and_return(false) end def unstub_domain_resolv @@ -183,6 +251,36 @@ describe Gitlab::UrlBlocker do end end + describe '#validate_hostname!' do + let(:ip_addresses) do + [ + '2001:db8:1f70::999:de8:7648:6e8', + 'FE80::C800:EFF:FE74:8', + '::ffff:127.0.0.1', + '::ffff:169.254.168.100', + '::ffff:7f00:1', + '0:0:0:0:0:ffff:0.0.0.0', + 'localhost', + '127.0.0.1', + '127.000.000.001', + '0x7f000001', + '0x7f.0.0.1', + '0x7f.0.0.1', + '017700000001', + '0177.1', + '2130706433', + '::', + '::1' + ] + end + + it 'does not raise error for valid Ip addresses' do + ip_addresses.each do |ip| + expect { described_class.send(:validate_hostname!, ip) }.not_to raise_error + end + end + end + # Resolv does not support resolving UTF-8 domain names # See https://bugs.ruby-lang.org/issues/4270 def stub_resolv diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 5390f237073..deb19fe1a4b 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -17,6 +17,9 @@ describe Gitlab::UsageData do gcp_cluster = create(:cluster, :provided_by_gcp) create(:cluster, :provided_by_user) create(:cluster, :provided_by_user, :disabled) + create(:cluster, :group) + create(:cluster, :group, :disabled) + create(:cluster, :group, :disabled) create(:clusters_applications_helm, :installed, cluster: gcp_cluster) create(:clusters_applications_ingress, :installed, cluster: gcp_cluster) create(:clusters_applications_cert_managers, :installed, cluster: gcp_cluster) @@ -77,7 +80,11 @@ describe Gitlab::UsageData do environments clusters clusters_enabled + project_clusters_enabled + group_clusters_enabled clusters_disabled + project_clusters_disabled + group_clusters_disabled clusters_platforms_gke clusters_platforms_user clusters_applications_helm @@ -127,8 +134,13 @@ describe Gitlab::UsageData do 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_enabled]).to eq(7) + expect(count_data[:project_clusters_enabled]).to eq(6) + expect(count_data[:group_clusters_enabled]).to eq(1) + expect(count_data[:clusters_disabled]).to eq(3) + expect(count_data[:project_clusters_disabled]).to eq(1) + expect(count_data[:group_clusters_disabled]).to eq(2) + expect(count_data[:group_clusters_enabled]).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) @@ -201,4 +213,29 @@ describe Gitlab::UsageData do expect(described_class.count(relation, fallback: 15)).to eq(15) end end + + describe '#approximate_counts' do + it 'gets approximate counts for selected models' do + create(:label) + + expect(Gitlab::Database::Count).to receive(:approximate_counts) + .with(described_class::APPROXIMATE_COUNT_MODELS).once.and_call_original + + counts = described_class.approximate_counts.values + + expect(counts.count).to eq(described_class::APPROXIMATE_COUNT_MODELS.count) + expect(counts.any? { |count| count < 0 }).to be_falsey + end + + it 'returns default values if counts can not be retrieved' do + described_class::APPROXIMATE_COUNT_MODELS.map do |model| + model.name.underscore.pluralize.to_sym + end + + expect(Gitlab::Database::Count).to receive(:approximate_counts) + .and_return({}) + + expect(described_class.approximate_counts.values.uniq).to eq([-1]) + end + end end diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb index ad2c9d7f2af..47a5fd0bdb4 100644 --- a/spec/lib/gitlab/utils_spec.rb +++ b/spec/lib/gitlab/utils_spec.rb @@ -2,7 +2,33 @@ require 'spec_helper' describe Gitlab::Utils do delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which, :ensure_array_from_string, - :bytes_to_megabytes, :append_path, to: :described_class + :bytes_to_megabytes, :append_path, :check_path_traversal!, to: :described_class + + describe '.check_path_traversal!' do + it 'detects path traversal at the start of the string' do + expect { check_path_traversal!('../foo') }.to raise_error(/Invalid path/) + end + + it 'detects path traversal at the start of the string, even to just the subdirectory' do + expect { check_path_traversal!('../') }.to raise_error(/Invalid path/) + end + + it 'detects path traversal in the middle of the string' do + expect { check_path_traversal!('foo/../../bar') }.to raise_error(/Invalid path/) + end + + it 'detects path traversal at the end of the string when slash-terminates' do + expect { check_path_traversal!('foo/../') }.to raise_error(/Invalid path/) + end + + it 'detects path traversal at the end of the string' do + expect { check_path_traversal!('foo/..') }.to raise_error(/Invalid path/) + end + + it 'does nothing for a safe string' do + expect(check_path_traversal!('./foo')).to eq('./foo') + end + end describe '.slugify' do { @@ -127,4 +153,42 @@ describe Gitlab::Utils do end end end + + describe '.ensure_utf8_size' do + context 'string is has less bytes than expected' do + it 'backfills string with null characters' do + transformed = described_class.ensure_utf8_size('a' * 10, bytes: 32) + + expect(transformed.bytesize).to eq 32 + expect(transformed).to eq(('a' * 10) + ('0' * 22)) + end + end + + context 'string size is exactly the one that is expected' do + it 'returns original value' do + transformed = described_class.ensure_utf8_size('a' * 32, bytes: 32) + + expect(transformed).to eq 'a' * 32 + expect(transformed.bytesize).to eq 32 + end + end + + context 'when string contains a few multi-byte UTF characters' do + it 'backfills string with null characters' do + transformed = described_class.ensure_utf8_size('❤' * 6, bytes: 32) + + expect(transformed).to eq '❤❤❤❤❤❤' + ('0' * 14) + expect(transformed.bytesize).to eq 32 + end + end + + context 'when string has multiple multi-byte UTF chars exceeding 32 bytes' do + it 'truncates string to 32 characters and backfills it if needed' do + transformed = described_class.ensure_utf8_size('❤' * 18, bytes: 32) + + expect(transformed).to eq(('❤' * 10) + ('0' * 2)) + expect(transformed.bytesize).to eq 32 + end + end + end end diff --git a/spec/lib/omni_auth/strategies/jwt_spec.rb b/spec/lib/omni_auth/strategies/jwt_spec.rb index 88d6d0b559a..c2e2db27362 100644 --- a/spec/lib/omni_auth/strategies/jwt_spec.rb +++ b/spec/lib/omni_auth/strategies/jwt_spec.rb @@ -4,12 +4,10 @@ describe OmniAuth::Strategies::Jwt do include Rack::Test::Methods include DeviseHelpers - context '.decoded' do - let(:strategy) { described_class.new({}) } + context '#decoded' do + subject { described_class.new({}) } let(:timestamp) { Time.now.to_i } let(:jwt_config) { Devise.omniauth_configs[:jwt] } - let(:key) { JWT.encode(claims, jwt_config.strategy.secret) } - let(:claims) do { id: 123, @@ -18,19 +16,55 @@ describe OmniAuth::Strategies::Jwt do iat: timestamp } end + let(:algorithm) { 'HS256' } + let(:secret) { jwt_config.strategy.secret } + let(:private_key) { secret } + let(:payload) { JWT.encode(claims, private_key, algorithm) } before do - allow_any_instance_of(OmniAuth::Strategy).to receive(:options).and_return(jwt_config.strategy) - allow_any_instance_of(Rack::Request).to receive(:params).and_return({ 'jwt' => key }) + subject.options[:secret] = secret + subject.options[:algorithm] = algorithm + + expect_next_instance_of(Rack::Request) do |rack_request| + expect(rack_request).to receive(:params).and_return('jwt' => payload) + end end - it 'decodes the user information' do - result = strategy.decoded + ECDSA_NAMED_CURVES = { + 'ES256' => 'prime256v1', + 'ES384' => 'secp384r1', + 'ES512' => 'secp521r1' + }.freeze - expect(result["id"]).to eq(123) - expect(result["name"]).to eq("user_example") - expect(result["email"]).to eq("user@example.com") - expect(result["iat"]).to eq(timestamp) + { + OpenSSL::PKey::RSA => %w[RS256 RS384 RS512], + OpenSSL::PKey::EC => %w[ES256 ES384 ES512], + String => %w[HS256 HS384 HS512] + }.each do |private_key_class, algorithms| + algorithms.each do |algorithm| + context "when the #{algorithm} algorithm is used" do + let(:algorithm) { algorithm } + let(:secret) do + if private_key_class == OpenSSL::PKey::RSA + private_key_class.generate(2048) + .to_pem + elsif private_key_class == OpenSSL::PKey::EC + private_key_class.new(ECDSA_NAMED_CURVES[algorithm]) + .tap { |key| key.generate_key! } + .to_pem + else + private_key_class.new(jwt_config.strategy.secret) + end + end + let(:private_key) { private_key_class ? private_key_class.new(secret) : secret } + + it 'decodes the user information' do + result = subject.decoded + + expect(result).to eq(claims.stringify_keys) + end + end + end end context 'required claims is missing' do @@ -43,7 +77,7 @@ describe OmniAuth::Strategies::Jwt do end it 'raises error' do - expect { strategy.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) + expect { subject.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) end end @@ -57,11 +91,12 @@ describe OmniAuth::Strategies::Jwt do end before do - jwt_config.strategy.valid_within = Time.now.to_i + # Omniauth config values are always strings! + subject.options[:valid_within] = 2.days.to_s end it 'raises error' do - expect { strategy.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) + expect { subject.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) end end @@ -76,11 +111,12 @@ describe OmniAuth::Strategies::Jwt do end before do - jwt_config.strategy.valid_within = 2.seconds + # Omniauth config values are always strings! + subject.options[:valid_within] = 2.seconds.to_s end it 'raises error' do - expect { strategy.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) + expect { subject.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) end end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index ff1a5aa2536..150c00e4bfe 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -522,7 +522,7 @@ describe Notify do let(:project_snippet) { create(:project_snippet, project: project) } let(:project_snippet_note) { create(:note_on_project_snippet, project: project, noteable: project_snippet) } - subject { described_class.note_snippet_email(project_snippet_note.author_id, project_snippet_note.id) } + subject { described_class.note_project_snippet_email(project_snippet_note.author_id, project_snippet_note.id) } it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do let(:model) { project_snippet } diff --git a/spec/migrations/backfill_store_project_full_path_in_repo_spec.rb b/spec/migrations/backfill_store_project_full_path_in_repo_spec.rb new file mode 100644 index 00000000000..34f4a36d63d --- /dev/null +++ b/spec/migrations/backfill_store_project_full_path_in_repo_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require Rails.root.join('db', 'post_migrate', '20181010133639_backfill_store_project_full_path_in_repo.rb') + +describe BackfillStoreProjectFullPathInRepo, :migration do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:group) { namespaces.create!(name: 'foo', path: 'foo') } + let(:subgroup) { namespaces.create!(name: 'bar', path: 'bar', parent_id: group.id) } + + subject(:migration) { described_class.new } + + around do |example| + Sidekiq::Testing.inline! do + example.run + end + end + + describe '#up' do + shared_examples_for 'writes the full path to git config' do + it 'writes the git config' do + expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |repository_service| + allow(repository_service).to receive(:cleanup) + expect(repository_service).to receive(:set_config).with('gitlab.fullpath' => expected_path) + end + + migration.up + end + + it 'retries in case of failure' do + repository_service = spy(:repository_service) + + allow(Gitlab::GitalyClient::RepositoryService).to receive(:new).and_return(repository_service) + + allow(repository_service).to receive(:set_config).and_raise(GRPC::BadStatus, 'Retry me') + expect(repository_service).to receive(:set_config).exactly(3).times + + migration.up + end + + it 'cleans up repository before writing the config' do + expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |repository_service| + expect(repository_service).to receive(:cleanup).ordered + expect(repository_service).to receive(:set_config).ordered + end + + migration.up + end + + context 'legacy storage' do + it 'finds the repository at the correct location' do + Project.find(project.id).create_repository + + expect { migration.up }.not_to raise_error + end + end + + context 'hashed storage' do + it 'finds the repository at the correct location' do + project.update_attribute(:storage_version, 1) + + Project.find(project.id).create_repository + + expect { migration.up }.not_to raise_error + end + end + end + + context 'project in group' do + let!(:project) { projects.create!(namespace_id: group.id, name: 'baz', path: 'baz') } + let(:expected_path) { 'foo/baz' } + + it_behaves_like 'writes the full path to git config' + end + + context 'project in subgroup' do + let!(:project) { projects.create!(namespace_id: subgroup.id, name: 'baz', path: 'baz') } + let(:expected_path) { 'foo/bar/baz' } + + it_behaves_like 'writes the full path to git config' + end + end + + describe '#down' do + context 'project in group' do + let!(:project) { projects.create!(namespace_id: group.id, name: 'baz', path: 'baz') } + + it 'deletes the gitlab full config value' do + expect_any_instance_of(Gitlab::GitalyClient::RepositoryService) + .to receive(:delete_config).with(['gitlab.fullpath']) + + migration.down + end + end + end +end diff --git a/spec/migrations/clean_up_for_members_spec.rb b/spec/migrations/clean_up_for_members_spec.rb index 0258860d169..7876536cb3e 100644 --- a/spec/migrations/clean_up_for_members_spec.rb +++ b/spec/migrations/clean_up_for_members_spec.rb @@ -3,6 +3,7 @@ require Rails.root.join('db', 'migrate', '20171216111734_clean_up_for_members.rb describe CleanUpForMembers, :migration do let(:migration) { described_class.new } + let(:groups) { table(:namespaces) } let!(:group_member) { create_group_member } let!(:unbinded_group_member) { create_group_member } let!(:invited_group_member) { create_group_member(true) } @@ -25,7 +26,7 @@ describe CleanUpForMembers, :migration do end def create_group_member(invited = false) - fill_member(GroupMember.new(group: create_group), invited) + fill_member(GroupMember.new(source_id: create_group.id, source_type: 'Namespace'), invited) end def create_project_member(invited = false) @@ -54,7 +55,7 @@ describe CleanUpForMembers, :migration do def create_group name = FFaker::Lorem.characters(10) - Group.create(name: name, path: name.downcase.gsub(/\s/, '_')) + groups.create!(type: 'Group', name: name, path: name.downcase.gsub(/\s/, '_')) end def create_project diff --git a/spec/migrations/cleanup_environments_external_url_spec.rb b/spec/migrations/cleanup_environments_external_url_spec.rb new file mode 100644 index 00000000000..07ddaf3d38f --- /dev/null +++ b/spec/migrations/cleanup_environments_external_url_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20181108091549_cleanup_environments_external_url.rb') + +describe CleanupEnvironmentsExternalUrl, :migration do + let(:environments) { table(:environments) } + let(:invalid_entries) { environments.where(environments.arel_table[:external_url].matches('javascript://%')) } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + + before do + namespace = namespaces.create(name: 'foo', path: 'foo') + project = projects.create!(namespace_id: namespace.id) + + environments.create!(id: 1, project_id: project.id, name: 'poisoned', slug: 'poisoned', external_url: 'javascript://alert("1")') + end + + it 'clears every environment with a javascript external_url' do + expect do + subject.up + end.to change { invalid_entries.count }.from(1).to(0) + end + + it 'do not removes environments' do + expect do + subject.up + end.not_to change { environments.count } + end +end diff --git a/spec/migrations/delete_inconsistent_internal_id_records_spec.rb b/spec/migrations/delete_inconsistent_internal_id_records_spec.rb index 4af51217031..8c55daf0d37 100644 --- a/spec/migrations/delete_inconsistent_internal_id_records_spec.rb +++ b/spec/migrations/delete_inconsistent_internal_id_records_spec.rb @@ -94,17 +94,18 @@ describe DeleteInconsistentInternalIdRecords, :migration do end context 'for milestones (by group)' do - # milestones (by group) is a little different than all of the other models - let!(:group1) { create(:group) } - let!(:group2) { create(:group) } - let!(:group3) { create(:group) } + # milestones (by group) is a little different than most of the other models + let(:groups) { table(:namespaces) } + let(:group1) { groups.create(name: 'Group 1', type: 'Group', path: 'group_1') } + let(:group2) { groups.create(name: 'Group 2', type: 'Group', path: 'group_2') } + let(:group3) { groups.create(name: 'Group 2', type: 'Group', path: 'group_3') } let(:internal_id_query) { ->(group) { InternalId.where(usage: InternalId.usages['milestones'], namespace: group) } } before do - 3.times { create(:milestone, group: group1) } - 3.times { create(:milestone, group: group2) } - 3.times { create(:milestone, group: group3) } + 3.times { create(:milestone, group_id: group1.id) } + 3.times { create(:milestone, group_id: group2.id) } + 3.times { create(:milestone, group_id: group3.id) } internal_id_query.call(group1).first.tap do |iid| iid.last_value = iid.last_value - 2 diff --git a/spec/migrations/migrate_forbidden_redirect_uris_spec.rb b/spec/migrations/migrate_forbidden_redirect_uris_spec.rb new file mode 100644 index 00000000000..0bc13a3974a --- /dev/null +++ b/spec/migrations/migrate_forbidden_redirect_uris_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20181026091631_migrate_forbidden_redirect_uris.rb') + +describe MigrateForbiddenRedirectUris, :migration do + let(:oauth_application) { table(:oauth_applications) } + let(:oauth_access_grant) { table(:oauth_access_grants) } + + let!(:control_app) { oauth_application.create(random_params) } + let!(:control_access_grant) { oauth_application.create(random_params) } + let!(:forbidden_js_app) { oauth_application.create(random_params.merge(redirect_uri: 'javascript://alert()')) } + let!(:forbidden_vb_app) { oauth_application.create(random_params.merge(redirect_uri: 'VBSCRIPT://alert()')) } + let!(:forbidden_access_grant) { oauth_application.create(random_params.merge(redirect_uri: 'vbscript://alert()')) } + + context 'oauth application' do + it 'migrates forbidden javascript URI' do + expect { migrate! }.to change { forbidden_js_app.reload.redirect_uri }.to('http://forbidden-scheme-has-been-overwritten') + end + + it 'migrates forbidden VBScript URI' do + expect { migrate! }.to change { forbidden_vb_app.reload.redirect_uri }.to('http://forbidden-scheme-has-been-overwritten') + end + + it 'does not migrate a valid URI' do + expect { migrate! }.not_to change { control_app.reload.redirect_uri } + end + end + + context 'access grant' do + it 'migrates forbidden VBScript URI' do + expect { migrate! }.to change { forbidden_access_grant.reload.redirect_uri }.to('http://forbidden-scheme-has-been-overwritten') + end + + it 'does not migrate a valid URI' do + expect { migrate! }.not_to change { control_access_grant.reload.redirect_uri } + end + end + + def random_params + { + name: 'test', + secret: 'test', + uid: Doorkeeper::OAuth::Helpers::UniqueToken.generate, + redirect_uri: 'http://valid.com' + } + end +end diff --git a/spec/migrations/migrate_issues_to_ghost_user_spec.rb b/spec/migrations/migrate_issues_to_ghost_user_spec.rb index 9220b49a736..0016f058a17 100644 --- a/spec/migrations/migrate_issues_to_ghost_user_spec.rb +++ b/spec/migrations/migrate_issues_to_ghost_user_spec.rb @@ -18,33 +18,33 @@ describe MigrateIssuesToGhostUser, :migration do let!(:ghost) { users.create(ghost: true, email: 'ghost@example.com') } it 'does not create a new user' do - expect { schema_migrate_up! }.not_to change { User.count } + expect { migrate! }.not_to change { User.count } end it 'migrates issues where author = nil to the ghost user' do - schema_migrate_up! + migrate! expect(issues.first.reload.author_id).to eq(ghost.id) end it 'does not change issues authored by an existing user' do - expect { schema_migrate_up! }.not_to change { issues.second.reload.author_id} + expect { migrate! }.not_to change { issues.second.reload.author_id} end end context 'when ghost user does not exist' do it 'creates a new user' do - expect { schema_migrate_up! }.to change { User.count }.by(1) + expect { migrate! }.to change { User.count }.by(1) end it 'migrates issues where author = nil to the ghost user' do - schema_migrate_up! + migrate! expect(issues.first.reload.author_id).to eq(User.ghost.id) end it 'does not change issues authored by an existing user' do - expect { schema_migrate_up! }.not_to change { issues.second.reload.author_id} + expect { migrate! }.not_to change { issues.second.reload.author_id} end end end diff --git a/spec/migrations/schedule_runners_token_encryption_spec.rb b/spec/migrations/schedule_runners_token_encryption_spec.rb new file mode 100644 index 00000000000..376d2795277 --- /dev/null +++ b/spec/migrations/schedule_runners_token_encryption_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20181121111200_schedule_runners_token_encryption') + +describe ScheduleRunnersTokenEncryption, :migration do + let(:settings) { table(:application_settings) } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:runners) { table(:ci_runners) } + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 1) + + settings.create!(id: 1, runners_registration_token: 'plain-text-token1') + namespaces.create!(id: 11, name: 'gitlab', path: 'gitlab-org', runners_token: 'my-token1') + namespaces.create!(id: 12, name: 'gitlab', path: 'gitlab-org', runners_token: 'my-token2') + projects.create!(id: 111, namespace_id: 11, name: 'gitlab', path: 'gitlab-ce', runners_token: 'my-token1') + projects.create!(id: 114, namespace_id: 11, name: 'gitlab', path: 'gitlab-ce', runners_token: 'my-token2') + runners.create!(id: 201, runner_type: 1, token: 'plain-text-token1') + runners.create!(id: 202, runner_type: 1, token: 'plain-text-token2') + end + + it 'schedules runners token encryption migration for multiple resources' do + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 'settings', 1, 1) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 'namespace', 11, 11) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(8.minutes, 'namespace', 12, 12) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 'project', 111, 111) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(8.minutes, 'project', 114, 114) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 'runner', 201, 201) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(8.minutes, 'runner', 202, 202) + expect(BackgroundMigrationWorker.jobs.size).to eq 7 + end + end + end +end diff --git a/spec/models/appearance_spec.rb b/spec/models/appearance_spec.rb index 77b07cf1ac9..35415030154 100644 --- a/spec/models/appearance_spec.rb +++ b/spec/models/appearance_spec.rb @@ -20,7 +20,7 @@ describe Appearance do end context 'with uploads' do - it_behaves_like 'model with mounted uploader', false do + it_behaves_like 'model with uploads', false do let(:model_object) { create(:appearance, :with_logo) } let(:upload_attribute) { :logo } let(:uploader_class) { AttachmentUploader } diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index ed93f94d893..e8c03b587e2 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -18,6 +18,7 @@ describe Blob do describe '.lazy' do let(:project) { create(:project, :repository) } + let(:same_project) { Project.find(project.id) } let(:other_project) { create(:project, :repository) } let(:commit_id) { 'e63f41fe459e62e1228fcef60d7189127aeba95a' } @@ -32,7 +33,7 @@ describe Blob do expect(other_project.repository).not_to receive(:blobs_at) changelog = described_class.lazy(project, commit_id, 'CHANGELOG') - contributing = described_class.lazy(project, commit_id, 'CONTRIBUTING.md') + contributing = described_class.lazy(same_project, commit_id, 'CONTRIBUTING.md') described_class.lazy(other_project, commit_id, 'CHANGELOG') diff --git a/spec/models/ci/build_metadata_spec.rb b/spec/models/ci/build_metadata_spec.rb index 6dba132184c..519968b9e48 100644 --- a/spec/models/ci/build_metadata_spec.rb +++ b/spec/models/ci/build_metadata_spec.rb @@ -15,6 +15,8 @@ describe Ci::BuildMetadata do let(:build) { create(:ci_build, pipeline: pipeline) } let(:build_metadata) { build.metadata } + it_behaves_like 'having unique enum values' + describe '#update_timeout_state' do subject { build_metadata } diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index d02c3a5765f..89f78f629d4 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -769,33 +769,15 @@ describe Ci::Build do let(:subject) { build.hide_secrets(data) } context 'hide runners token' do - let(:data) { 'new token data'} + let(:data) { "new #{project.runners_token} data"} - before do - build.project.update(runners_token: 'token') - end - - it { is_expected.to eq('new xxxxx data') } + it { is_expected.to match(/^new x+ data$/) } end context 'hide build token' do - let(:data) { 'new token data'} - - before do - build.update(token: 'token') - end - - it { is_expected.to eq('new xxxxx data') } - end - - context 'hide build token' do - let(:data) { 'new token data'} - - before do - build.update(token: 'token') - end + let(:data) { "new #{build.token} data"} - it { is_expected.to eq('new xxxxx data') } + it { is_expected.to match(/^new x+ data$/) } end end @@ -1943,7 +1925,7 @@ describe Ci::Build do context 'when token is empty' do before do - build.token = nil + build.update_columns(token: nil, token_encrypted: nil) end it { is_expected.to be_nil} @@ -2159,7 +2141,7 @@ describe Ci::Build do end before do - build.token = 'my-token' + build.set_token('my-token') build.yaml_variables = [] end diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb index 59c861a74db..d214fdf369a 100644 --- a/spec/models/ci/build_trace_chunk_spec.rb +++ b/spec/models/ci/build_trace_chunk_spec.rb @@ -12,6 +12,8 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do described_class.new(build: build, chunk_index: chunk_index, data_store: data_store, raw_data: raw_data) end + it_behaves_like 'having unique enum values' + before do stub_feature_flags(ci_enable_live_trace: true) stub_artifacts_object_storage @@ -436,32 +438,47 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :redis } context 'when data exists' do - let(:data) { 'Sample data in redis' } - before do build_trace_chunk.send(:unsafe_set_data!, data) end - it 'persists the data' do - expect(build_trace_chunk.redis?).to be_truthy - expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to eq(data) - expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil - expect { Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk) }.to raise_error(Excon::Error::NotFound) + context 'when data size reached CHUNK_SIZE' do + let(:data) { 'a' * described_class::CHUNK_SIZE } - subject + it 'persists the data' do + expect(build_trace_chunk.redis?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to eq(data) + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect { Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk) }.to raise_error(Excon::Error::NotFound) + + subject + + expect(build_trace_chunk.fog?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + end - expect(build_trace_chunk.fog?).to be_truthy - expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil - expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil - expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + it_behaves_like 'Atomic operation' end - it_behaves_like 'Atomic operation' + context 'when data size has not reached CHUNK_SIZE' do + let(:data) { 'Sample data in redis' } + + it 'does not persist the data and the orignal data is intact' do + expect { subject }.to raise_error(described_class::FailedToPersistDataError) + + expect(build_trace_chunk.redis?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to eq(data) + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect { Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk) }.to raise_error(Excon::Error::NotFound) + end + end end context 'when data does not exist' do it 'does not persist' do - expect { subject }.to raise_error('Can not persist empty data') + expect { subject }.to raise_error(described_class::FailedToPersistDataError) end end end @@ -470,32 +487,47 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :database } context 'when data exists' do - let(:data) { 'Sample data in database' } - before do build_trace_chunk.send(:unsafe_set_data!, data) end - it 'persists the data' do - expect(build_trace_chunk.database?).to be_truthy - expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil - expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to eq(data) - expect { Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk) }.to raise_error(Excon::Error::NotFound) + context 'when data size reached CHUNK_SIZE' do + let(:data) { 'a' * described_class::CHUNK_SIZE } - subject + it 'persists the data' do + expect(build_trace_chunk.database?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to eq(data) + expect { Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk) }.to raise_error(Excon::Error::NotFound) + + subject + + expect(build_trace_chunk.fog?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + end - expect(build_trace_chunk.fog?).to be_truthy - expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil - expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil - expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + it_behaves_like 'Atomic operation' end - it_behaves_like 'Atomic operation' + context 'when data size has not reached CHUNK_SIZE' do + let(:data) { 'Sample data in database' } + + it 'does not persist the data and the orignal data is intact' do + expect { subject }.to raise_error(described_class::FailedToPersistDataError) + + expect(build_trace_chunk.database?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to eq(data) + expect { Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk) }.to raise_error(Excon::Error::NotFound) + end + end end context 'when data does not exist' do it 'does not persist' do - expect { subject }.to raise_error('Can not persist empty data') + expect { subject }.to raise_error(described_class::FailedToPersistDataError) end end end @@ -504,27 +536,37 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :fog } context 'when data exists' do - let(:data) { 'Sample data in fog' } - before do build_trace_chunk.send(:unsafe_set_data!, data) end - it 'does not change data store' do - expect(build_trace_chunk.fog?).to be_truthy - expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil - expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil - expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + context 'when data size reached CHUNK_SIZE' do + let(:data) { 'a' * described_class::CHUNK_SIZE } - subject + it 'does not change data store' do + expect(build_trace_chunk.fog?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + + subject - expect(build_trace_chunk.fog?).to be_truthy - expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil - expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil - expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + expect(build_trace_chunk.fog?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + end + + it_behaves_like 'Atomic operation' end - it_behaves_like 'Atomic operation' + context 'when data size has not reached CHUNK_SIZE' do + let(:data) { 'Sample data in fog' } + + it 'does not raise error' do + expect { subject }.not_to raise_error + end + end end end end diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index fb5bec4108a..c68ba02b8de 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -15,6 +15,8 @@ describe Ci::JobArtifact do it { is_expected.to delegate_method(:open).to(:file) } it { is_expected.to delegate_method(:exists?).to(:file) } + it_behaves_like 'having unique enum values' + describe '.test_reports' do subject { described_class.test_reports } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 9e6146b8a44..b67c6a4cffa 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -8,10 +8,13 @@ describe Ci::Pipeline, :mailer do create(:ci_empty_pipeline, status: :created, project: project) end + it_behaves_like 'having unique enum values' + it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:auto_canceled_by) } it { is_expected.to belong_to(:pipeline_schedule) } + it { is_expected.to belong_to(:merge_request) } it { is_expected.to have_many(:statuses) } it { is_expected.to have_many(:trigger_requests) } @@ -30,8 +33,131 @@ describe Ci::Pipeline, :mailer do describe 'associations' do it 'has a bidirectional relationship with projects' do - expect(described_class.reflect_on_association(:project).has_inverse?).to eq(:pipelines) - expect(Project.reflect_on_association(:pipelines).has_inverse?).to eq(:project) + expect(described_class.reflect_on_association(:project).has_inverse?).to eq(:all_pipelines) + expect(Project.reflect_on_association(:all_pipelines).has_inverse?).to eq(:project) + expect(Project.reflect_on_association(:ci_pipelines).has_inverse?).to eq(:project) + end + end + + describe '.sort_by_merge_request_pipelines' do + subject { described_class.sort_by_merge_request_pipelines } + + context 'when branch pipelines exist' do + let!(:branch_pipeline_1) { create(:ci_pipeline, source: :push) } + let!(:branch_pipeline_2) { create(:ci_pipeline, source: :push) } + + it 'returns pipelines order by id' do + expect(subject).to eq([branch_pipeline_2, + branch_pipeline_1]) + end + end + + context 'when merge request pipelines exist' do + let!(:merge_request_pipeline_1) do + create(:ci_pipeline, source: :merge_request, merge_request: merge_request) + end + + let!(:merge_request_pipeline_2) do + create(:ci_pipeline, source: :merge_request, merge_request: merge_request) + end + + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: 'feature', + target_project: project, + target_branch: 'master') + end + + it 'returns pipelines order by id' do + expect(subject).to eq([merge_request_pipeline_2, + merge_request_pipeline_1]) + end + end + + context 'when both branch pipeline and merge request pipeline exist' do + let!(:branch_pipeline_1) { create(:ci_pipeline, source: :push) } + let!(:branch_pipeline_2) { create(:ci_pipeline, source: :push) } + + let!(:merge_request_pipeline_1) do + create(:ci_pipeline, source: :merge_request, merge_request: merge_request) + end + + let!(:merge_request_pipeline_2) do + create(:ci_pipeline, source: :merge_request, merge_request: merge_request) + end + + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: 'feature', + target_project: project, + target_branch: 'master') + end + + it 'returns merge request pipeline first' do + expect(subject).to eq([merge_request_pipeline_2, + merge_request_pipeline_1, + branch_pipeline_2, + branch_pipeline_1]) + end + end + end + + describe '.merge_request' do + subject { described_class.merge_request } + + context 'when there is a merge request pipeline' do + let!(:pipeline) { create(:ci_pipeline, source: :merge_request, merge_request: merge_request) } + let(:merge_request) { create(:merge_request) } + + it 'returns merge request pipeline first' do + expect(subject).to eq([pipeline]) + end + end + + context 'when there are no merge request pipelines' do + let!(:pipeline) { create(:ci_pipeline, source: :push) } + + it 'returns empty array' do + expect(subject).to be_empty + end + end + end + + describe 'Validations for merge request pipelines' do + let(:pipeline) { build(:ci_pipeline, source: source, merge_request: merge_request) } + + context 'when source is merge request' do + let(:source) { :merge_request } + + context 'when merge request is specified' do + let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_project: project, target_branch: 'master') } + + it { expect(pipeline).to be_valid } + end + + context 'when merge request is empty' do + let(:merge_request) { nil } + + it { expect(pipeline).not_to be_valid } + end + end + + context 'when source is web' do + let(:source) { :web } + + context 'when merge request is specified' do + let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_project: project, target_branch: 'master') } + + it { expect(pipeline).not_to be_valid } + end + + context 'when merge request is empty' do + let(:merge_request) { nil } + + it { expect(pipeline).to be_valid } + end end end @@ -224,6 +350,50 @@ describe Ci::Pipeline, :mailer do CI_COMMIT_TITLE CI_COMMIT_DESCRIPTION] end + + context 'when source is merge request' do + let(:pipeline) do + create(:ci_pipeline, source: :merge_request, merge_request: merge_request) + end + + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: 'feature', + target_project: project, + target_branch: 'master') + end + + it 'exposes merge request pipeline variables' do + expect(subject.to_hash) + .to include( + 'CI_MERGE_REQUEST_ID' => merge_request.id.to_s, + 'CI_MERGE_REQUEST_IID' => merge_request.iid.to_s, + 'CI_MERGE_REQUEST_REF_PATH' => merge_request.ref_path.to_s, + 'CI_MERGE_REQUEST_PROJECT_ID' => merge_request.project.id.to_s, + 'CI_MERGE_REQUEST_PROJECT_PATH' => merge_request.project.full_path, + 'CI_MERGE_REQUEST_PROJECT_URL' => merge_request.project.web_url, + 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME' => merge_request.target_branch.to_s, + 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID' => merge_request.source_project.id.to_s, + 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH' => merge_request.source_project.full_path, + 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL' => merge_request.source_project.web_url, + 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME' => merge_request.source_branch.to_s) + end + + context 'when source project does not exist' do + before do + merge_request.update_column(:source_project_id, nil) + end + + it 'does not expose source project related variables' do + expect(subject.to_hash.keys).not_to include( + %w[CI_MERGE_REQUEST_SOURCE_PROJECT_ID + CI_MERGE_REQUEST_SOURCE_PROJECT_PATH + CI_MERGE_REQUEST_SOURCE_PROJECT_URL + CI_MERGE_REQUEST_SOURCE_BRANCH_NAME]) + end + end + end end describe '#protected_ref?' do @@ -758,27 +928,85 @@ describe Ci::Pipeline, :mailer do describe '#branch?' do subject { pipeline.branch? } - context 'is not a tag' do + context 'when ref is not a tag' do before do pipeline.tag = false end - it 'return true when tag is set to false' do + it 'return true' do is_expected.to be_truthy end + + context 'when source is merge request' do + let(:pipeline) do + create(:ci_pipeline, source: :merge_request, merge_request: merge_request) + end + + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: 'feature', + target_project: project, + target_branch: 'master') + end + + it 'returns false' do + is_expected.to be_falsey + end + end end - context 'is not a tag' do + context 'when ref is a tag' do before do pipeline.tag = true end - it 'return false when tag is set to true' do + it 'return false' do is_expected.to be_falsey end end end + describe '#git_ref' do + subject { pipeline.send(:git_ref) } + + context 'when ref is branch' do + let(:pipeline) { create(:ci_pipeline, tag: false) } + + it 'returns branch ref' do + is_expected.to eq(Gitlab::Git::BRANCH_REF_PREFIX + pipeline.ref.to_s) + end + end + + context 'when ref is tag' do + let(:pipeline) { create(:ci_pipeline, tag: true) } + + it 'returns branch ref' do + is_expected.to eq(Gitlab::Git::TAG_REF_PREFIX + pipeline.ref.to_s) + end + end + + context 'when ref is merge request' do + let(:pipeline) do + create(:ci_pipeline, + source: :merge_request, + merge_request: merge_request) + end + + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: 'feature', + target_project: project, + target_branch: 'master') + end + + it 'returns branch ref' do + is_expected.to eq(Gitlab::Git::BRANCH_REF_PREFIX + pipeline.ref.to_s) + end + end + end + describe 'ref_exists?' do context 'when repository exists' do using RSpec::Parameterized::TableSyntax @@ -1003,7 +1231,7 @@ describe Ci::Pipeline, :mailer do create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop') create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline2, name: 'rubocop') - pipelines = project.pipelines.to_a + pipelines = project.ci_pipelines.to_a pipelines.each(&:number_of_warnings) @@ -1247,22 +1475,40 @@ describe Ci::Pipeline, :mailer do describe '#ci_yaml_file_path' do subject { pipeline.ci_yaml_file_path } - it 'returns the path from project' do - allow(pipeline.project).to receive(:ci_config_path) { 'custom/path' } + %i[unknown_source repository_source].each do |source| + context source.to_s do + before do + pipeline.config_source = described_class.config_sources.fetch(source) + end - is_expected.to eq('custom/path') - end + it 'returns the path from project' do + allow(pipeline.project).to receive(:ci_config_path) { 'custom/path' } + + is_expected.to eq('custom/path') + end + + it 'returns default when custom path is nil' do + allow(pipeline.project).to receive(:ci_config_path) { nil } + + is_expected.to eq('.gitlab-ci.yml') + end - it 'returns default when custom path is nil' do - allow(pipeline.project).to receive(:ci_config_path) { nil } + it 'returns default when custom path is empty' do + allow(pipeline.project).to receive(:ci_config_path) { '' } - is_expected.to eq('.gitlab-ci.yml') + is_expected.to eq('.gitlab-ci.yml') + end + end end - it 'returns default when custom path is empty' do - allow(pipeline.project).to receive(:ci_config_path) { '' } + context 'when pipeline is for auto-devops' do + before do + pipeline.config_source = 'auto_devops_source' + end - is_expected.to eq('.gitlab-ci.yml') + it 'does not return config file' do + is_expected.to be_nil + end end end @@ -1835,6 +2081,55 @@ describe Ci::Pipeline, :mailer do expect(pipeline.all_merge_requests).to be_empty end + + context 'when there is a merge request pipeline' do + let(:source_branch) { 'feature' } + let(:target_branch) { 'master' } + + let!(:pipeline) do + create(:ci_pipeline, + source: :merge_request, + project: project, + ref: source_branch, + merge_request: merge_request) + end + + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: source_branch, + target_project: project, + target_branch: target_branch) + end + + it 'returns an associated merge request' do + expect(pipeline.all_merge_requests).to eq([merge_request]) + end + + context 'when there is another merge request pipeline that targets a different branch' do + let(:target_branch_2) { 'merge-test' } + + let!(:pipeline_2) do + create(:ci_pipeline, + source: :merge_request, + project: project, + ref: source_branch, + merge_request: merge_request_2) + end + + let(:merge_request_2) do + create(:merge_request, + source_project: project, + source_branch: source_branch, + target_project: project, + target_branch: target_branch_2) + end + + it 'does not return an associated merge request' do + expect(pipeline.all_merge_requests).not_to include(merge_request_2) + end + end + end end describe '#stuck?' do diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index b545e036aa1..ad79f8d4ce0 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Ci::Runner do + it_behaves_like 'having unique enum values' + describe 'validation' do it { is_expected.to validate_presence_of(:access_level) } it { is_expected.to validate_presence_of(:runner_type) } diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index 5076f7faeac..3228c400155 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Ci::Stage, :models do let(:stage) { create(:ci_stage_entity) } + it_behaves_like 'having unique enum values' + describe 'associations' do before do create(:ci_build, stage_id: stage.id) diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index cfe0e216c78..cd28f1fe9c6 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -3,6 +3,8 @@ require 'rails_helper' describe Clusters::Applications::Ingress do let(:ingress) { create(:clusters_applications_ingress) } + it_behaves_like 'having unique enum values' + include_examples 'cluster application core specs', :clusters_applications_ingress include_examples 'cluster application status specs', :clusters_applications_ingress include_examples 'cluster application helm specs', :clusters_applications_ingress diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb index d43d88c2924..a1579b90436 100644 --- a/spec/models/clusters/applications/knative_spec.rb +++ b/spec/models/clusters/applications/knative_spec.rb @@ -1,6 +1,9 @@ require 'rails_helper' describe Clusters::Applications::Knative do + include KubernetesHelpers + include ReactiveCachingHelpers + let(:knative) { create(:clusters_applications_knative) } include_examples 'cluster application core specs', :clusters_applications_knative @@ -121,4 +124,43 @@ describe Clusters::Applications::Knative do describe 'validations' do it { is_expected.to validate_presence_of(:hostname) } end + + describe '#services' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:service) { cluster.platform_kubernetes } + let(:knative) { create(:clusters_applications_knative, cluster: cluster) } + + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: cluster.cluster_project.project) + end + + subject { knative.services } + + before do + stub_kubeclient_discover(service.api_url) + stub_kubeclient_knative_services + end + + it 'should have an unintialized cache' do + is_expected.to be_nil + end + + context 'when using synchronous reactive cache' do + before do + stub_reactive_cache(knative, services: kube_response(kube_knative_services_body)) + synchronous_reactive_cache(knative) + end + + it 'should have cached services' do + is_expected.not_to be_nil + end + + it 'should match our namespace' do + expect(knative.services_for(ns: namespace)).not_to be_nil + end + end + end end diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index 97e50809647..47daa79873e 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -18,7 +18,7 @@ describe Clusters::Applications::Runner do let(:application) { create(:clusters_applications_runner, :scheduled, version: '0.1.30') } it 'updates the application version' do - expect(application.reload.version).to eq('0.1.38') + expect(application.reload.version).to eq('0.1.39') end end end @@ -46,7 +46,7 @@ describe Clusters::Applications::Runner do it 'should be initialized with 4 arguments' do expect(subject.name).to eq('runner') expect(subject.chart).to eq('runner/gitlab-runner') - expect(subject.version).to eq('0.1.38') + expect(subject.version).to eq('0.1.39') expect(subject).not_to be_rbac expect(subject.repository).to eq('https://charts.gitlab.io') expect(subject.files).to eq(gitlab_runner.files) @@ -64,7 +64,7 @@ describe Clusters::Applications::Runner do let(:gitlab_runner) { create(:clusters_applications_runner, :errored, runner: ci_runner, version: '0.1.13') } it 'should be initialized with the locked version' do - expect(subject.version).to eq('0.1.38') + expect(subject.version).to eq('0.1.39') end end end diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index eb68ebccdcb..840f74c9890 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Clusters::Cluster do + it_behaves_like 'having unique enum values' + it { is_expected.to belong_to(:user) } it { is_expected.to have_many(:cluster_projects) } it { is_expected.to have_many(:projects) } @@ -90,6 +92,26 @@ describe Clusters::Cluster do it { is_expected.to contain_exactly(cluster) } end + describe '.missing_kubernetes_namespace' do + let!(:cluster) { create(:cluster, :provided_by_gcp, :project) } + let(:project) { cluster.project } + let(:kubernetes_namespaces) { project.kubernetes_namespaces } + + subject do + described_class.joins(:projects).where(projects: { id: project.id }).missing_kubernetes_namespace(kubernetes_namespaces) + end + + it { is_expected.to contain_exactly(cluster) } + + context 'kubernetes namespace exists' do + before do + create(:cluster_kubernetes_namespace, project: project, cluster: cluster) + end + + it { is_expected.to be_empty } + end + end + describe 'validation' do subject { cluster.valid? } @@ -231,6 +253,81 @@ describe Clusters::Cluster do end end + describe '.ancestor_clusters_for_clusterable' do + let(:group_cluster) { create(:cluster, :provided_by_gcp, :group) } + let(:group) { group_cluster.group } + let(:hierarchy_order) { :desc } + let(:clusterable) { project } + + subject do + described_class.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: hierarchy_order) + end + + context 'when project does not belong to this group' do + let(:project) { create(:project, group: create(:group)) } + + it 'returns nothing' do + is_expected.to be_empty + end + end + + context 'when group has a configured kubernetes cluster' do + let(:project) { create(:project, group: group) } + + it 'returns the group cluster' do + is_expected.to eq([group_cluster]) + end + end + + context 'when sub-group has configured kubernetes cluster', :nested_groups do + let(:sub_group_cluster) { create(:cluster, :provided_by_gcp, :group) } + let(:sub_group) { sub_group_cluster.group } + let(:project) { create(:project, group: sub_group) } + + before do + sub_group.update!(parent: group) + end + + it 'returns clusters in order, descending the hierachy' do + is_expected.to eq([group_cluster, sub_group_cluster]) + end + + it 'avoids N+1 queries' do + another_project = create(:project) + control_count = ActiveRecord::QueryRecorder.new do + described_class.ancestor_clusters_for_clusterable(another_project, hierarchy_order: hierarchy_order) + end.count + + cluster2 = create(:cluster, :provided_by_gcp, :group) + child2 = cluster2.group + child2.update!(parent: sub_group) + project = create(:project, group: child2) + + expect do + described_class.ancestor_clusters_for_clusterable(project, hierarchy_order: hierarchy_order) + end.not_to exceed_query_limit(control_count) + end + + context 'for a group' do + let(:clusterable) { sub_group } + + it 'returns clusters in order for a group' do + is_expected.to eq([group_cluster]) + end + end + end + + context 'scope chaining' do + let(:project) { create(:project, group: group) } + + subject { described_class.none.ancestor_clusters_for_clusterable(project) } + + it 'returns nothing' do + is_expected.to be_empty + end + end + end + describe '#provider' do subject { cluster.provider } @@ -263,6 +360,31 @@ describe Clusters::Cluster do end end + describe '#all_projects' do + let(:project) { create(:project) } + let(:cluster) { create(:cluster, projects: [project]) } + + subject { cluster.all_projects } + + context 'project cluster' do + it 'returns project' do + is_expected.to eq([project]) + end + end + + context 'group cluster' do + let(:cluster) { create(:cluster, :group) } + let(:group) { cluster.group } + let(:project) { create(:project, group: group) } + let(:subgroup) { create(:group, parent: group) } + let(:subproject) { create(:project, group: subgroup) } + + it 'returns all projects for group' do + is_expected.to contain_exactly(project, subproject) + end + end + end + describe '#first_project' do subject { cluster.first_project } diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb index 99fd6ccc4d8..062d2fd0768 100644 --- a/spec/models/clusters/platforms/kubernetes_spec.rb +++ b/spec/models/clusters/platforms/kubernetes_spec.rb @@ -18,6 +18,8 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching it { is_expected.to delegate_method(:managed?).to(:cluster) } it { is_expected.to delegate_method(:kubernetes_namespace).to(:cluster) } + it_behaves_like 'having unique enum values' + describe 'before_validation' do context 'when namespace includes upper case' do let(:kubernetes) { create(:cluster_platform_kubernetes, :configured, namespace: namespace) } @@ -273,6 +275,36 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching ) end end + + context 'group level cluster' do + let!(:cluster) { create(:cluster, :group, platform_kubernetes: kubernetes) } + + let(:project) { create(:project, group: cluster.group) } + + subject { kubernetes.predefined_variables(project: project) } + + context 'no kubernetes namespace for the project' do + it_behaves_like 'setting variables' + + it 'does not return KUBE_TOKEN' do + expect(subject).not_to include( + { key: 'KUBE_TOKEN', value: kubernetes.token, public: false } + ) + end + end + + context 'kubernetes namespace exists for the project' do + let!(:kubernetes_namespace) { create(:cluster_kubernetes_namespace, :with_token, cluster: cluster, project: project) } + + it_behaves_like 'setting variables' + + it 'sets KUBE_TOKEN' do + expect(subject).to include( + { key: 'KUBE_TOKEN', value: kubernetes_namespace.service_account_token, public: false } + ) + end + end + end end describe '#terminals' do diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 2a0039a0635..a2d2d77746d 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -204,7 +204,7 @@ describe Commit do message = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis id blandit. Vivamus egestas lacinia lacus, sed rutrum mauris.' allow(commit).to receive(:safe_message).and_return(message) - expect(commit.title).to eq('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis…') + expect(commit.title).to eq('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id...') end it "truncates a message with a newline before 80 characters at the newline" do diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 917685399d4..8b7c88805c1 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -13,6 +13,8 @@ describe CommitStatus do create(:commit_status, pipeline: pipeline, **opts) end + it_behaves_like 'having unique enum values' + it { is_expected.to belong_to(:pipeline) } it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:project) } diff --git a/spec/models/concerns/chronic_duration_attribute_spec.rb b/spec/models/concerns/chronic_duration_attribute_spec.rb index 8847623f705..b14b773b653 100644 --- a/spec/models/concerns/chronic_duration_attribute_spec.rb +++ b/spec/models/concerns/chronic_duration_attribute_spec.rb @@ -54,7 +54,8 @@ shared_examples 'ChronicDurationAttribute writer' 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']) + expect(subject.errors&.messages) + .to include(base: ['Maximum job timeout has a value which could not be accepted']) end end diff --git a/spec/models/concerns/deployment_platform_spec.rb b/spec/models/concerns/deployment_platform_spec.rb index 7bb89fe41dc..19ab4382b53 100644 --- a/spec/models/concerns/deployment_platform_spec.rb +++ b/spec/models/concerns/deployment_platform_spec.rb @@ -43,13 +43,86 @@ describe DeploymentPlatform do it { is_expected.to be_nil } end - context 'when user configured kubernetes from CI/CD > Clusters' do + context 'when project has configured kubernetes from CI/CD > Clusters' do let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } let(:platform_kubernetes) { cluster.platform_kubernetes } it 'returns the Kubernetes platform' do expect(subject).to eq(platform_kubernetes) end + + context 'with a group level kubernetes cluster' do + let(:group_cluster) { create(:cluster, :provided_by_gcp, :group) } + + before do + project.update!(group: group_cluster.group) + end + + it 'returns the Kubernetes platform from the project cluster' do + expect(subject).to eq(platform_kubernetes) + end + end + end + + context 'when group has configured kubernetes cluster' do + let!(:group_cluster) { create(:cluster, :provided_by_gcp, :group) } + let(:group) { group_cluster.group } + + before do + project.update!(group: group) + end + + it 'returns the Kubernetes platform' do + is_expected.to eq(group_cluster.platform_kubernetes) + end + + context 'when child group has configured kubernetes cluster', :nested_groups do + let!(:child_group1_cluster) { create(:cluster, :provided_by_gcp, :group) } + let(:child_group1) { child_group1_cluster.group } + + before do + project.update!(group: child_group1) + child_group1.update!(parent: group) + end + + it 'returns the Kubernetes platform for the child group' do + is_expected.to eq(child_group1_cluster.platform_kubernetes) + end + + context 'deeply nested group' do + let!(:child_group2_cluster) { create(:cluster, :provided_by_gcp, :group) } + let(:child_group2) { child_group2_cluster.group } + + before do + child_group2.update!(parent: child_group1) + project.update!(group: child_group2) + end + + it 'returns most nested group cluster Kubernetes platform' do + is_expected.to eq(child_group2_cluster.platform_kubernetes) + end + + context 'cluster in the middle of hierarchy is disabled' do + before do + child_group2_cluster.update!(enabled: false) + end + + it 'returns closest enabled Kubenetes platform' do + is_expected.to eq(child_group1_cluster.platform_kubernetes) + end + end + end + end + + context 'feature flag disabled' do + before do + stub_feature_flags(group_clusters: false) + end + + it 'returns nil' do + is_expected.to be_nil + end + end end context 'when user configured kubernetes integration from project services' do diff --git a/spec/models/concerns/discussion_on_diff_spec.rb b/spec/models/concerns/discussion_on_diff_spec.rb index 8cd129dc851..73eb7a1160d 100644 --- a/spec/models/concerns/discussion_on_diff_spec.rb +++ b/spec/models/concerns/discussion_on_diff_spec.rb @@ -12,6 +12,34 @@ describe DiscussionOnDiff do expect(truncated_lines.count).to be <= DiffDiscussion::NUMBER_OF_TRUNCATED_DIFF_LINES end + + context 'with truncated diff lines diff limit set' do + let(:truncated_lines) do + subject.truncated_diff_lines( + diff_limit: diff_limit + ) + end + + context 'when diff limit is higher than default' do + let(:diff_limit) { DiffDiscussion::NUMBER_OF_TRUNCATED_DIFF_LINES + 1 } + + it 'returns fewer lines than the default' do + expect(subject.diff_lines.count).to be > diff_limit + + expect(truncated_lines.count).to be <= DiffDiscussion::NUMBER_OF_TRUNCATED_DIFF_LINES + end + end + + context 'when diff_limit is lower than default' do + let(:diff_limit) { 3 } + + it 'returns fewer lines than the default' do + expect(subject.diff_lines.count).to be > DiffDiscussion::NUMBER_OF_TRUNCATED_DIFF_LINES + + expect(truncated_lines.count).to be <= diff_limit + end + end + end end context "when some diff lines are meta" do diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb index 782687516ae..55d83bc3a6b 100644 --- a/spec/models/concerns/token_authenticatable_spec.rb +++ b/spec/models/concerns/token_authenticatable_spec.rb @@ -21,44 +21,59 @@ end describe ApplicationSetting, 'TokenAuthenticatable' do let(:token_field) { :runners_registration_token } + let(:settings) { described_class.new } + it_behaves_like 'TokenAuthenticatable' describe 'generating new token' do context 'token is not generated yet' do describe 'token field accessor' do - subject { described_class.new.send(token_field) } + subject { settings.send(token_field) } + it { is_expected.not_to be_blank } end - describe 'ensured token' do - subject { described_class.new.send("ensure_#{token_field}") } + describe "ensure_runners_registration_token" do + subject { settings.send("ensure_#{token_field}") } it { is_expected.to be_a String } it { is_expected.not_to be_blank } + + it 'does not persist token' do + expect(settings).not_to be_persisted + end end - describe 'ensured! token' do - subject { described_class.new.send("ensure_#{token_field}!") } + describe 'ensure_runners_registration_token!' do + subject { settings.send("ensure_#{token_field}!") } - it 'persists new token' do - expect(subject).to eq described_class.current[token_field] + it 'persists new token as an encrypted string' do + expect(subject).to eq settings.reload.runners_registration_token + expect(settings.read_attribute('runners_registration_token_encrypted')) + .to eq Gitlab::CryptoHelper.aes256_gcm_encrypt(subject) + expect(settings).to be_persisted + end + + it 'does not persist token in a clear text' do + expect(subject).not_to eq settings.reload + .read_attribute('runners_registration_token_encrypted') end end end context 'token is generated' do before do - subject.send("reset_#{token_field}!") + settings.send("reset_#{token_field}!") end it 'persists a new token' do - expect(subject.send(:read_attribute, token_field)).to be_a String + expect(settings.runners_registration_token).to be_a String end end end describe 'setting new token' do - subject { described_class.new.send("set_#{token_field}", '0123456789') } + subject { settings.send("set_#{token_field}", '0123456789') } it { is_expected.to eq '0123456789' } end @@ -336,3 +351,89 @@ describe PersonalAccessToken, 'TokenAuthenticatable' do end end end + +describe Ci::Build, 'TokenAuthenticatable' do + let(:token_field) { :token } + let(:build) { FactoryBot.build(:ci_build) } + + it_behaves_like 'TokenAuthenticatable' + + describe 'generating new token' do + context 'token is not generated yet' do + describe 'token field accessor' do + it 'makes it possible to access token' do + expect(build.token).to be_nil + + build.save! + + expect(build.token).to be_present + end + end + + describe "ensure_token" do + subject { build.ensure_token } + + it { is_expected.to be_a String } + it { is_expected.not_to be_blank } + + it 'does not persist token' do + expect(build).not_to be_persisted + end + end + + describe 'ensure_token!' do + it 'persists a new token' do + expect(build.ensure_token!).to eq build.reload.token + expect(build).to be_persisted + end + + it 'persists new token as an encrypted string' do + build.ensure_token! + + encrypted = Gitlab::CryptoHelper.aes256_gcm_encrypt(build.token) + + expect(build.read_attribute('token_encrypted')).to eq encrypted + end + + it 'does not persist a token in a clear text' do + build.ensure_token! + + expect(build.read_attribute('token')).to be_nil + end + end + end + + describe '#reset_token!' do + it 'persists a new token' do + build.save! + + build.token.yield_self do |previous_token| + build.reset_token! + + expect(build.token).not_to eq previous_token + expect(build.token).to be_a String + end + end + end + end + + describe 'setting a new token' do + subject { build.set_token('0123456789') } + + it 'returns the token' do + expect(subject).to eq '0123456789' + end + + it 'writes a new encrypted token' do + expect(build.read_attribute('token_encrypted')).to be_nil + expect(subject).to eq '0123456789' + expect(build.read_attribute('token_encrypted')).to be_present + end + + it 'does not write a new cleartext token' do + expect(build.read_attribute('token')).to be_nil + expect(subject).to eq '0123456789' + expect(build.read_attribute('token')).to be_nil + end + end +end diff --git a/spec/models/concerns/token_authenticatable_strategies/base_spec.rb b/spec/models/concerns/token_authenticatable_strategies/base_spec.rb new file mode 100644 index 00000000000..6605f1f5a5f --- /dev/null +++ b/spec/models/concerns/token_authenticatable_strategies/base_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe TokenAuthenticatableStrategies::Base do + let(:instance) { double(:instance) } + let(:field) { double(:field) } + + describe '.fabricate' do + context 'when digest stragegy is specified' do + it 'fabricates digest strategy object' do + strategy = described_class.fabricate(instance, field, digest: true) + + expect(strategy).to be_a TokenAuthenticatableStrategies::Digest + end + end + + context 'when encrypted strategy is specified' do + it 'fabricates encrypted strategy object' do + strategy = described_class.fabricate(instance, field, encrypted: true) + + expect(strategy).to be_a TokenAuthenticatableStrategies::Encrypted + end + end + + context 'when no strategy is specified' do + it 'fabricates insecure strategy object' do + strategy = described_class.fabricate(instance, field, something: true) + + expect(strategy).to be_a TokenAuthenticatableStrategies::Insecure + end + end + + context 'when incompatible options are provided' do + it 'raises an error' do + expect { described_class.fabricate(instance, field, digest: true, encrypted: true) } + .to raise_error ArgumentError + end + end + end + + describe '#fallback?' do + context 'when fallback is set' do + it 'recognizes fallback setting' do + strategy = described_class.new(instance, field, fallback: true) + + expect(strategy.fallback?).to be true + end + end + + context 'when fallback is not a valid value' do + it 'raises an error' do + strategy = described_class.new(instance, field, fallback: 'something') + + expect { strategy.fallback? }.to raise_error ArgumentError + end + end + + context 'when fallback is not set' do + it 'raises an error' do + strategy = described_class.new(instance, field, {}) + + expect(strategy.fallback?).to eq false + end + end + end +end diff --git a/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb b/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb new file mode 100644 index 00000000000..93cab80cb1f --- /dev/null +++ b/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb @@ -0,0 +1,156 @@ +require 'spec_helper' + +describe TokenAuthenticatableStrategies::Encrypted do + let(:model) { double(:model) } + let(:instance) { double(:instance) } + + let(:encrypted) do + Gitlab::CryptoHelper.aes256_gcm_encrypt('my-value') + end + + subject do + described_class.new(model, 'some_field', options) + end + + describe '.new' do + context 'when fallback and migration strategies are set' do + let(:options) { { fallback: true, migrating: true } } + + it 'raises an error' do + expect { subject }.to raise_error ArgumentError, /not compatible/ + end + end + end + + describe '#find_token_authenticatable' do + context 'when using fallback strategy' do + let(:options) { { fallback: true } } + + it 'finds the encrypted resource by cleartext' do + allow(model).to receive(:find_by) + .with('some_field_encrypted' => encrypted) + .and_return('encrypted resource') + + expect(subject.find_token_authenticatable('my-value')) + .to eq 'encrypted resource' + end + + it 'uses insecure strategy when encrypted token cannot be found' do + allow(subject.send(:insecure_strategy)) + .to receive(:find_token_authenticatable) + .and_return('plaintext resource') + + allow(model).to receive(:find_by) + .with('some_field_encrypted' => encrypted) + .and_return(nil) + + expect(subject.find_token_authenticatable('my-value')) + .to eq 'plaintext resource' + end + end + + context 'when using migration strategy' do + let(:options) { { migrating: true } } + + it 'finds the cleartext resource by cleartext' do + allow(model).to receive(:find_by) + .with('some_field' => 'my-value') + .and_return('cleartext resource') + + expect(subject.find_token_authenticatable('my-value')) + .to eq 'cleartext resource' + end + + it 'returns nil if resource cannot be found' do + allow(model).to receive(:find_by) + .with('some_field' => 'my-value') + .and_return(nil) + + expect(subject.find_token_authenticatable('my-value')) + .to be_nil + end + end + end + + describe '#get_token' do + context 'when using fallback strategy' do + let(:options) { { fallback: true } } + + it 'returns decrypted token when an encrypted token is present' do + allow(instance).to receive(:read_attribute) + .with('some_field_encrypted') + .and_return(encrypted) + + expect(subject.get_token(instance)).to eq 'my-value' + end + + it 'returns the plaintext token when encrypted token is not present' do + allow(instance).to receive(:read_attribute) + .with('some_field_encrypted') + .and_return(nil) + + allow(instance).to receive(:read_attribute) + .with('some_field') + .and_return('cleartext value') + + expect(subject.get_token(instance)).to eq 'cleartext value' + end + end + + context 'when using migration strategy' do + let(:options) { { migrating: true } } + + it 'returns cleartext token when an encrypted token is present' do + allow(instance).to receive(:read_attribute) + .with('some_field_encrypted') + .and_return(encrypted) + + allow(instance).to receive(:read_attribute) + .with('some_field') + .and_return('my-cleartext-value') + + expect(subject.get_token(instance)).to eq 'my-cleartext-value' + end + + it 'returns the cleartext token when encrypted token is not present' do + allow(instance).to receive(:read_attribute) + .with('some_field_encrypted') + .and_return(nil) + + allow(instance).to receive(:read_attribute) + .with('some_field') + .and_return('cleartext value') + + expect(subject.get_token(instance)).to eq 'cleartext value' + end + end + end + + describe '#set_token' do + context 'when using fallback strategy' do + let(:options) { { fallback: true } } + + it 'writes encrypted token and removes plaintext token and returns it' do + expect(instance).to receive(:[]=) + .with('some_field_encrypted', encrypted) + expect(instance).to receive(:[]=) + .with('some_field', nil) + + expect(subject.set_token(instance, 'my-value')).to eq 'my-value' + end + end + + context 'when using migration strategy' do + let(:options) { { migrating: true } } + + it 'writes encrypted token and writes plaintext token' do + expect(instance).to receive(:[]=) + .with('some_field_encrypted', encrypted) + expect(instance).to receive(:[]=) + .with('some_field', 'my-value') + + expect(subject.set_token(instance, 'my-value')).to eq 'my-value' + end + end + end +end diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 270b2767c68..a8d53cfcd7d 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -16,6 +16,8 @@ describe Deployment do it { is_expected.to validate_presence_of(:ref) } it { is_expected.to validate_presence_of(:sha) } + it_behaves_like 'having unique enum values' + describe '#scheduled_actions' do subject { deployment.scheduled_actions } diff --git a/spec/models/environment_status_spec.rb b/spec/models/environment_status_spec.rb index 90f7e4a4590..9da16dea929 100644 --- a/spec/models/environment_status_spec.rb +++ b/spec/models/environment_status_spec.rb @@ -92,16 +92,12 @@ describe EnvironmentStatus do end describe '.build_environments_status' do - subject { described_class.send(:build_environments_status, merge_request, user, sha) } + subject { described_class.send(:build_environments_status, merge_request, user, pipeline) } let!(:build) { create(:ci_build, :deploy_to_production, pipeline: pipeline) } let(:environment) { build.deployment.environment } let(:user) { project.owner } - before do - build.deployment&.update!(sha: sha) - end - context 'when environment is created on a forked project' do let(:project) { create(:project, :repository) } let(:forked) { fork_project(project, user, repository: true) } @@ -160,6 +156,39 @@ describe EnvironmentStatus do expect(subject.count).to eq(0) end end + + context 'when multiple deployments with the same SHA in different environments' do + let(:pipeline2) { create(:ci_pipeline, sha: sha, project: project) } + let!(:build2) { create(:ci_build, :start_review_app, pipeline: pipeline2) } + + it 'returns deployments related to the head pipeline' do + expect(subject.count).to eq(1) + expect(subject[0].environment).to eq(environment) + expect(subject[0].merge_request).to eq(merge_request) + expect(subject[0].sha).to eq(sha) + end + end + + context 'when multiple deployments in the same pipeline for the same environments' do + let!(:build2) { create(:ci_build, :deploy_to_production, pipeline: pipeline) } + + it 'returns unique entries' do + expect(subject.count).to eq(1) + expect(subject[0].environment).to eq(environment) + expect(subject[0].merge_request).to eq(merge_request) + expect(subject[0].sha).to eq(sha) + end + end + + context 'when environment is stopped' do + before do + environment.stop! + end + + it 'does not return environment status' do + expect(subject.count).to eq(0) + end + end end end end diff --git a/spec/models/gpg_signature_spec.rb b/spec/models/gpg_signature_spec.rb index 0136bb61c07..cdd7dea2064 100644 --- a/spec/models/gpg_signature_spec.rb +++ b/spec/models/gpg_signature_spec.rb @@ -8,6 +8,8 @@ RSpec.describe GpgSignature do let(:gpg_key) { create(:gpg_key) } let(:gpg_key_subkey) { create(:gpg_key_subkey) } + it_behaves_like 'having unique enum values' + describe 'associations' do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:gpg_key) } diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index ada00f03928..e63881242f6 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -76,7 +76,7 @@ describe Group do before do group.add_developer(user) - sub_group.add_developer(user) + sub_group.add_maintainer(user) end it 'also gets notification settings from parent groups' do @@ -498,7 +498,7 @@ describe Group do it 'returns member users on every nest level without duplication' do group.add_developer(user_a) nested_group.add_developer(user_b) - deep_nested_group.add_developer(user_a) + deep_nested_group.add_maintainer(user_a) expect(group.users_with_descendants).to contain_exactly(user_a, user_b) expect(nested_group.users_with_descendants).to contain_exactly(user_a, user_b) @@ -739,10 +739,39 @@ describe Group do end context 'with uploads' do - it_behaves_like 'model with mounted uploader', true do + it_behaves_like 'model with uploads', true do let(:model_object) { create(:group, :with_avatar) } let(:upload_attribute) { :avatar } let(:uploader_class) { AttachmentUploader } end end + + describe '#group_clusters_enabled?' do + before do + # Override global stub in spec/spec_helper.rb + expect(Feature).to receive(:enabled?).and_call_original + end + + subject { group.group_clusters_enabled? } + + it { is_expected.to be_truthy } + + context 'explicitly disabled for root ancestor' do + before do + feature = Feature.get(:group_clusters) + feature.disable(group.root_ancestor) + end + + it { is_expected.to be_falsey } + end + + context 'explicitly disabled for root ancestor' do + before do + feature = Feature.get(:group_clusters) + feature.enable(group.root_ancestor) + end + + it { is_expected.to be_truthy } + end + end end diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb index 52c00a74b4b..4696341c05f 100644 --- a/spec/models/internal_id_spec.rb +++ b/spec/models/internal_id_spec.rb @@ -7,6 +7,8 @@ describe InternalId do let(:scope) { { project: project } } let(:init) { ->(s) { s.project.issues.size } } + it_behaves_like 'having unique enum values' + context 'validations' do it { is_expected.to validate_presence_of(:usage) } end diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb index 17dc27bd132..a51580f8292 100644 --- a/spec/models/list_spec.rb +++ b/spec/models/list_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe List do + it_behaves_like 'having unique enum values' + describe 'relationships' do it { is_expected.to belong_to(:board) } it { is_expected.to belong_to(:label) } diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index fca1b1f90d9..188beac1582 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -53,6 +53,29 @@ describe Member do expect(member).to be_valid end end + + context "when a child member inherits its access level" do + let(:user) { create(:user) } + let(:member) { create(:group_member, :developer, user: user) } + let(:child_group) { create(:group, parent: member.group) } + let(:child_member) { build(:group_member, group: child_group, user: user) } + + it "requires a higher level" do + child_member.access_level = GroupMember::REPORTER + + child_member.validate + + expect(child_member).not_to be_valid + end + + it "is valid with a higher level" do + child_member.access_level = GroupMember::MAINTAINER + + child_member.validate + + expect(child_member).to be_valid + end + end end describe 'Scopes & finders' do diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb index 97959ed4304..a3451c67bd8 100644 --- a/spec/models/members/group_member_spec.rb +++ b/spec/models/members/group_member_spec.rb @@ -50,4 +50,26 @@ describe GroupMember do group_member.destroy end end + + context 'access levels', :nested_groups do + context 'with parent group' do + it_behaves_like 'inherited access level as a member of entity' do + let(:entity) { create(:group, parent: parent_entity) } + end + end + + context 'with parent group and a sub subgroup' do + it_behaves_like 'inherited access level as a member of entity' do + let(:subgroup) { create(:group, parent: parent_entity) } + let(:entity) { create(:group, parent: subgroup) } + end + + context 'when only the subgroup has the member' do + it_behaves_like 'inherited access level as a member of entity' do + let(:parent_entity) { create(:group, parent: create(:group)) } + let(:entity) { create(:group, parent: parent_entity) } + end + end + end + end end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 334d4f95f53..097b1bb30dc 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -124,4 +124,19 @@ describe ProjectMember do end it_behaves_like 'members notifications', :project + + context 'access levels' do + context 'with parent group' do + it_behaves_like 'inherited access level as a member of entity' do + let(:entity) { create(:project, group: parent_entity) } + end + end + + context 'with parent group and a subgroup', :nested_groups do + it_behaves_like 'inherited access level as a member of entity' do + let(:subgroup) { create(:group, parent: parent_entity) } + let(:entity) { create(:project, group: subgroup) } + end + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index ad55c280399..9b60054e14a 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1206,6 +1206,119 @@ describe MergeRequest do expect(subject.all_pipelines).to contain_exactly(pipeline) end end + + context 'when pipelines exist for the branch and merge request' do + let(:source_ref) { 'feature' } + let(:target_ref) { 'master' } + + let!(:branch_pipeline) do + create(:ci_pipeline, + source: :push, + project: project, + ref: source_ref, + sha: shas.second) + end + + let!(:merge_request_pipeline) do + create(:ci_pipeline, + source: :merge_request, + project: project, + ref: source_ref, + sha: shas.second, + merge_request: merge_request) + end + + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: source_ref, + target_project: project, + target_branch: target_ref) + end + + let(:project) { create(:project, :repository) } + let(:shas) { project.repository.commits(source_ref, limit: 2).map(&:id) } + + before do + allow(merge_request).to receive(:all_commit_shas) { shas } + end + + it 'returns merge request pipeline first' do + expect(merge_request.all_pipelines) + .to eq([merge_request_pipeline, + branch_pipeline]) + end + + context 'when there are a branch pipeline and a merge request pipeline' do + let!(:branch_pipeline_2) do + create(:ci_pipeline, + source: :push, + project: project, + ref: source_ref, + sha: shas.first) + end + + let!(:merge_request_pipeline_2) do + create(:ci_pipeline, + source: :merge_request, + project: project, + ref: source_ref, + sha: shas.first, + merge_request: merge_request) + end + + it 'returns merge request pipelines first' do + expect(merge_request.all_pipelines) + .to eq([merge_request_pipeline_2, + merge_request_pipeline, + branch_pipeline_2, + branch_pipeline]) + end + end + + context 'when there are multiple merge request pipelines from the same branch' do + let!(:branch_pipeline_2) do + create(:ci_pipeline, + source: :push, + project: project, + ref: source_ref, + sha: shas.first) + end + + let!(:merge_request_pipeline_2) do + create(:ci_pipeline, + source: :merge_request, + project: project, + ref: source_ref, + sha: shas.first, + merge_request: merge_request_2) + end + + let(:merge_request_2) do + create(:merge_request, + source_project: project, + source_branch: source_ref, + target_project: project, + target_branch: 'stable') + end + + before do + allow(merge_request_2).to receive(:all_commit_shas) { shas } + end + + it 'returns only related merge request pipelines' do + expect(merge_request.all_pipelines) + .to eq([merge_request_pipeline, + branch_pipeline_2, + branch_pipeline]) + + expect(merge_request_2.all_pipelines) + .to eq([merge_request_pipeline_2, + branch_pipeline_2, + branch_pipeline]) + end + end + end end describe '#has_test_reports?' do diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 2db42fe802a..18b54cce834 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -249,7 +249,7 @@ describe Namespace do move_dir_result end - expect(Gitlab::Sentry).to receive(:should_raise?).and_return(false) # like prod + expect(Gitlab::Sentry).to receive(:should_raise_for_dev?).and_return(false) # like prod namespace.update(path: namespace.full_path + '_new') end @@ -538,7 +538,7 @@ describe Namespace do it 'returns member users on every nest level without duplication' do group.add_developer(user_a) nested_group.add_developer(user_b) - deep_nested_group.add_developer(user_a) + deep_nested_group.add_maintainer(user_a) expect(group.users_with_descendants).to contain_exactly(user_a, user_b) expect(nested_group.users_with_descendants).to contain_exactly(user_a, user_b) @@ -560,6 +560,7 @@ describe Namespace do let!(:project2) { create(:project_empty_repo, namespace: child) } it { expect(group.all_projects.to_a).to match_array([project2, project1]) } + it { expect(child.all_projects.to_a).to match_array([project2]) } end describe '#all_pipelines' do @@ -720,6 +721,7 @@ describe Namespace do deep_nested_group = create(:group, parent: nested_group) very_deep_nested_group = create(:group, parent: deep_nested_group) + expect(root_group.root_ancestor).to eq(root_group) expect(nested_group.root_ancestor).to eq(root_group) expect(deep_nested_group.root_ancestor).to eq(root_group) expect(very_deep_nested_group.root_ancestor).to eq(root_group) diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index f9be61e4768..bcdfe3cf1eb 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -517,7 +517,7 @@ describe Note do describe '#to_ability_name' do it 'returns snippet for a project snippet note' do - expect(build(:note_on_project_snippet).to_ability_name).to eq('snippet') + expect(build(:note_on_project_snippet).to_ability_name).to eq('project_snippet') end it 'returns personal_snippet for a personal snippet note' do diff --git a/spec/models/notification_setting_spec.rb b/spec/models/notification_setting_spec.rb index e545b674b4f..771d834c4bc 100644 --- a/spec/models/notification_setting_spec.rb +++ b/spec/models/notification_setting_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe NotificationSetting do + it_behaves_like 'having unique enum values' + describe "Associations" do it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:source) } diff --git a/spec/models/pool_repository_spec.rb b/spec/models/pool_repository_spec.rb new file mode 100644 index 00000000000..541e78507e5 --- /dev/null +++ b/spec/models/pool_repository_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PoolRepository do + describe 'associations' do + it { is_expected.to belong_to(:shard) } + it { is_expected.to have_many(:member_projects) } + end + + describe 'validations' do + let!(:pool_repository) { create(:pool_repository) } + + it { is_expected.to validate_presence_of(:shard) } + end + + describe '#disk_path' do + it 'sets the hashed disk_path' do + pool = create(:pool_repository) + + elements = File.split(pool.disk_path) + + expect(elements).to all( match(/\d{2,}/) ) + end + end +end diff --git a/spec/models/project_auto_devops_spec.rb b/spec/models/project_auto_devops_spec.rb index 342798f730b..7ff64c76e37 100644 --- a/spec/models/project_auto_devops_spec.rb +++ b/spec/models/project_auto_devops_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe ProjectAutoDevops do set(:project) { build(:project) } + it_behaves_like 'having unique enum values' + it { is_expected.to belong_to(:project) } it { is_expected.to define_enum_for(:deploy_strategy) } diff --git a/spec/models/project_import_state_spec.rb b/spec/models/project_import_state_spec.rb index f7033b28c76..e3b2d971419 100644 --- a/spec/models/project_import_state_spec.rb +++ b/spec/models/project_import_state_spec.rb @@ -10,4 +10,116 @@ describe ProjectImportState, type: :model do describe 'validations' do it { is_expected.to validate_presence_of(:project) } end + + describe 'Project import job' do + let(:import_state) { create(:import_state, import_url: generate(:url)) } + let(:project) { import_state.project } + + before do + allow_any_instance_of(Gitlab::GitalyClient::RepositoryService).to receive(:import_repository) + .with(project.import_url).and_return(true) + + # Works around https://github.com/rspec/rspec-mocks/issues/910 + allow(Project).to receive(:find).with(project.id).and_return(project) + expect(project.repository).to receive(:after_import).and_call_original + expect(project.wiki.repository).to receive(:after_import).and_call_original + end + + it 'imports a project' do + expect(RepositoryImportWorker).to receive(:perform_async).and_call_original + + expect { import_state.schedule }.to change { import_state.jid } + expect(import_state.status).to eq('finished') + end + end + + describe '#human_status_name' do + context 'when import_state exists' do + it 'returns the humanized status name' do + import_state = build(:import_state, :started) + + expect(import_state.human_status_name).to eq("started") + end + end + end + + describe 'import state transitions' do + context 'state transition: [:started] => [:finished]' do + let(:after_import_service) { spy(:after_import_service) } + let(:housekeeping_service) { spy(:housekeeping_service) } + + before do + allow(Projects::AfterImportService) + .to receive(:new) { after_import_service } + + allow(after_import_service) + .to receive(:execute) { housekeeping_service.execute } + + allow(Projects::HousekeepingService) + .to receive(:new) { housekeeping_service } + end + + it 'resets last_error' do + error_message = 'Some error' + import_state = create(:import_state, :started, last_error: error_message) + + expect { import_state.finish }.to change { import_state.last_error }.from(error_message).to(nil) + end + + it 'performs housekeeping when an import of a fresh project is completed' do + project = create(:project_empty_repo, :import_started, import_type: :github) + + project.import_state.finish + + expect(after_import_service).to have_received(:execute) + expect(housekeeping_service).to have_received(:execute) + end + + it 'does not perform housekeeping when project repository does not exist' do + project = create(:project, :import_started, import_type: :github) + + project.import_state.finish + + expect(housekeeping_service).not_to have_received(:execute) + end + + it 'does not perform housekeeping when project does not have a valid import type' do + project = create(:project, :import_started, import_type: nil) + + project.import_state.finish + + expect(housekeeping_service).not_to have_received(:execute) + end + end + end + + describe '#remove_jid', :clean_gitlab_redis_cache do + let(:project) { } + + context 'without an JID' do + it 'does nothing' do + import_state = create(:import_state) + + expect(Gitlab::SidekiqStatus) + .not_to receive(:unset) + + import_state.remove_jid + end + end + + context 'with an JID' do + it 'unsets the JID' do + import_state = create(:import_state, jid: '123') + + expect(Gitlab::SidekiqStatus) + .to receive(:unset) + .with('123') + .and_call_original + + import_state.remove_jid + + expect(import_state.jid).to be_nil + end + end + end end diff --git a/spec/models/project_repository_spec.rb b/spec/models/project_repository_spec.rb new file mode 100644 index 00000000000..c966447fedc --- /dev/null +++ b/spec/models/project_repository_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ProjectRepository do + describe 'associations' do + it { is_expected.to belong_to(:shard) } + it { is_expected.to belong_to(:project) } + end + + describe '.find_project' do + it 'finds project by disk path' do + project = create(:project) + project.track_project_repository + + expect(described_class.find_project(project.disk_path)).to eq(project) + end + + it 'returns nil when it does not find the project' do + expect(described_class.find_project('@@unexisting/path/to/project')).to be_nil + end + end +end diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index f2cb927df37..b6cf4c72450 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -13,6 +13,23 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do it { is_expected.to belong_to :project } end + context 'redirects' do + it 'does not follow redirects' do + redirect_to = 'https://redirected.example.com' + redirect_req_stub = stub_prometheus_request(prometheus_query_url('1'), status: 302, headers: { location: redirect_to }) + redirected_req_stub = stub_prometheus_request(redirect_to, body: { 'status': 'success' }) + + result = service.test + + # result = { success: false, result: error } + expect(result[:success]).to be_falsy + expect(result[:result]).to be_instance_of(Gitlab::PrometheusClient::Error) + + expect(redirect_req_stub).to have_been_requested + expect(redirected_req_stub).not_to have_been_requested + end + end + describe 'Validations' do context 'when manual_configuration is enabled' do before do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 47c331fbc7a..93c83fd21fd 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -4,6 +4,8 @@ describe Project do include ProjectForksHelper include GitHelpers + it_behaves_like 'having unique enum values' + describe 'associations' do it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:namespace) } @@ -54,13 +56,14 @@ describe Project do it { is_expected.to have_one(:gitlab_issue_tracker_service) } it { is_expected.to have_one(:external_wiki_service) } it { is_expected.to have_one(:project_feature) } + it { is_expected.to have_one(:project_repository) } it { is_expected.to have_one(:statistics).class_name('ProjectStatistics') } it { is_expected.to have_one(:import_data).class_name('ProjectImportData') } it { is_expected.to have_one(:last_event).class_name('Event') } it { is_expected.to have_one(:forked_from_project).through(:fork_network_member) } it { is_expected.to have_one(:auto_devops).class_name('ProjectAutoDevops') } it { is_expected.to have_many(:commit_statuses) } - it { is_expected.to have_many(:pipelines) } + it { is_expected.to have_many(:ci_pipelines) } it { is_expected.to have_many(:builds) } it { is_expected.to have_many(:build_trace_section_names)} it { is_expected.to have_many(:runner_projects) } @@ -84,6 +87,7 @@ describe Project do it { is_expected.to have_many(:pipeline_schedules) } it { is_expected.to have_many(:members_and_requesters) } it { is_expected.to have_many(:clusters) } + it { is_expected.to have_many(:kubernetes_namespaces) } it { is_expected.to have_many(:custom_attributes).class_name('ProjectCustomAttribute') } it { is_expected.to have_many(:project_badges).class_name('ProjectBadge') } it { is_expected.to have_many(:lfs_file_locks) } @@ -139,6 +143,29 @@ describe Project do expect(subject.boards.size).to eq 1 end end + + describe 'ci_pipelines association' do + context 'when feature flag pipeline_ci_sources_only is enabled' do + it 'returns only pipelines from ci_sources' do + stub_feature_flags(pipeline_ci_sources_only: true) + + expect(Ci::Pipeline).to receive(:ci_sources).and_call_original + + subject.ci_pipelines + end + end + + context 'when feature flag pipeline_ci_sources_only is disabled' do + it 'returns all pipelines' do + stub_feature_flags(pipeline_ci_sources_only: false) + + expect(Ci::Pipeline).not_to receive(:ci_sources).and_call_original + expect(Ci::Pipeline).to receive(:all).and_call_original.at_least(:once) + + subject.ci_pipelines + end + end + end end describe 'modules' do @@ -151,6 +178,24 @@ describe Project do it { is_expected.to include_module(Sortable) } end + describe '.missing_kubernetes_namespace' do + let!(:project) { create(:project) } + let!(:cluster) { create(:cluster, :provided_by_user, :group) } + let(:kubernetes_namespaces) { project.kubernetes_namespaces } + + subject { described_class.missing_kubernetes_namespace(kubernetes_namespaces) } + + it { is_expected.to contain_exactly(project) } + + context 'kubernetes namespace exists' do + before do + create(:cluster_kubernetes_namespace, project: project, cluster: cluster) + end + + it { is_expected.to be_empty } + end + end + describe 'validation' do let!(:project) { create(:project) } @@ -218,76 +263,93 @@ describe Project do end end - it 'does not allow an invalid URI as import_url' do - project = build(:project, import_url: 'invalid://') + describe 'import_url' do + it 'does not allow an invalid URI as import_url' do + project = build(:project, import_url: 'invalid://') - expect(project).not_to be_valid - end + expect(project).not_to be_valid + end - it 'does allow a SSH URI as import_url for persisted projects' do - project = create(:project) - project.import_url = 'ssh://test@gitlab.com/project.git' + it 'does allow a SSH URI as import_url for persisted projects' do + project = create(:project) + project.import_url = 'ssh://test@gitlab.com/project.git' - expect(project).to be_valid - end + expect(project).to be_valid + end - it 'does not allow a SSH URI as import_url for new projects' do - project = build(:project, import_url: 'ssh://test@gitlab.com/project.git') + it 'does not allow a SSH URI as import_url for new projects' do + project = build(:project, import_url: 'ssh://test@gitlab.com/project.git') - expect(project).not_to be_valid - end + expect(project).not_to be_valid + end - it 'does allow a valid URI as import_url' do - project = build(:project, import_url: 'http://gitlab.com/project.git') + it 'does allow a valid URI as import_url' do + project = build(:project, import_url: 'http://gitlab.com/project.git') - expect(project).to be_valid - end + expect(project).to be_valid + end - it 'allows an empty URI' do - project = build(:project, import_url: '') + it 'allows an empty URI' do + project = build(:project, import_url: '') - expect(project).to be_valid - end + expect(project).to be_valid + end - it 'does not produce import data on an empty URI' do - project = build(:project, import_url: '') + it 'does not produce import data on an empty URI' do + project = build(:project, import_url: '') - expect(project.import_data).to be_nil - end + expect(project.import_data).to be_nil + end - it 'does not produce import data on an invalid URI' do - project = build(:project, import_url: 'test://') + it 'does not produce import data on an invalid URI' do + project = build(:project, import_url: 'test://') - expect(project.import_data).to be_nil - end + expect(project.import_data).to be_nil + end - it "does not allow import_url pointing to localhost" do - project = build(:project, import_url: 'http://localhost:9000/t.git') + it "does not allow import_url pointing to localhost" do + project = build(:project, import_url: 'http://localhost:9000/t.git') - expect(project).to be_invalid - expect(project.errors[:import_url].first).to include('Requests to localhost are not allowed') - end + expect(project).to be_invalid + expect(project.errors[:import_url].first).to include('Requests to localhost are not allowed') + end - it "does not allow import_url with invalid ports for new projects" do - project = build(:project, import_url: 'http://github.com:25/t.git') + it "does not allow import_url with invalid ports for new projects" do + project = build(:project, import_url: 'http://github.com:25/t.git') - expect(project).to be_invalid - expect(project.errors[:import_url].first).to include('Only allowed ports are 80, 443') - end + expect(project).to be_invalid + expect(project.errors[:import_url].first).to include('Only allowed ports are 80, 443') + end - it "does not allow import_url with invalid ports for persisted projects" do - project = create(:project) - project.import_url = 'http://github.com:25/t.git' + it "does not allow import_url with invalid ports for persisted projects" do + project = create(:project) + project.import_url = 'http://github.com:25/t.git' - expect(project).to be_invalid - expect(project.errors[:import_url].first).to include('Only allowed ports are 22, 80, 443') - end + expect(project).to be_invalid + expect(project.errors[:import_url].first).to include('Only allowed ports are 22, 80, 443') + end - it "does not allow import_url with invalid user" do - project = build(:project, import_url: 'http://$user:password@github.com/t.git') + it "does not allow import_url with invalid user" do + project = build(:project, import_url: 'http://$user:password@github.com/t.git') - expect(project).to be_invalid - expect(project.errors[:import_url].first).to include('Username needs to start with an alphanumeric character') + expect(project).to be_invalid + expect(project.errors[:import_url].first).to include('Username needs to start with an alphanumeric character') + end + + include_context 'invalid urls' + + it 'does not allow urls with CR or LF characters' do + project = build(:project) + + aggregate_failures do + urls_with_CRLF.each do |url| + project.import_url = url + + expect(project).not_to be_valid + expect(project.errors.full_messages.first).to match(/is blocked: URI is invalid/) + end + end + end end describe 'project pending deletion' do @@ -373,6 +435,8 @@ describe Project do it { is_expected.to delegate_method(:members).to(:team).with_prefix(true) } it { is_expected.to delegate_method(:name).to(:owner).with_prefix(true).with_arguments(allow_nil: true) } + it { is_expected.to delegate_method(:group_clusters_enabled?).to(:group).with_arguments(allow_nil: true) } + it { is_expected.to delegate_method(:root_ancestor).to(:namespace).with_arguments(allow_nil: true) } end describe '#to_reference_with_postfix' do @@ -1601,6 +1665,30 @@ describe Project do end end + describe '#track_project_repository' do + let(:project) { create(:project, :repository) } + + it 'creates a project_repository' do + project.track_project_repository + + expect(project.reload.project_repository).to be_present + expect(project.project_repository.disk_path).to eq(project.disk_path) + expect(project.project_repository.shard_name).to eq(project.repository_storage) + end + + it 'updates the project_repository' do + project.track_project_repository + + allow(project).to receive(:disk_path).and_return('@fancy/new/path') + + expect do + project.track_project_repository + end.not_to change(ProjectRepository, :count) + + expect(project.reload.project_repository.disk_path).to eq(project.disk_path) + end + end + describe '#create_repository' do let(:project) { create(:project, :repository) } let(:shell) { Gitlab::Shell.new } @@ -1686,6 +1774,16 @@ describe Project do end end + describe 'handling import URL' do + it 'returns the sanitized URL' do + project = create(:project, :import_started, import_url: 'http://user:pass@test.com') + + project.import_state.finish + + expect(project.reload.import_url).to eq('http://test.com') + end + end + describe '#container_registry_url' do let(:project) { create(:project) } @@ -1799,106 +1897,6 @@ describe Project do end end - describe '#human_import_status_name' do - context 'when import_state exists' do - it 'returns the humanized status name' do - project = create(:project) - create(:import_state, :started, project: project) - - expect(project.human_import_status_name).to eq("started") - end - end - - context 'when import_state was not created yet' do - let(:project) { create(:project, :import_started) } - - it 'ensures import_state is created and returns humanized status name' do - expect do - project.human_import_status_name - end.to change { ProjectImportState.count }.from(0).to(1) - end - - it 'returns humanized status name' do - expect(project.human_import_status_name).to eq("started") - end - end - end - - describe 'Project import job' do - let(:project) { create(:project, import_url: generate(:url)) } - - before do - allow_any_instance_of(Gitlab::Shell).to receive(:import_repository) - .with(project.repository_storage, project.disk_path, project.import_url) - .and_return(true) - - # Works around https://github.com/rspec/rspec-mocks/issues/910 - allow(described_class).to receive(:find).with(project.id).and_return(project) - expect(project.repository).to receive(:after_import) - .and_call_original - expect(project.wiki.repository).to receive(:after_import) - .and_call_original - end - - it 'imports a project' do - expect_any_instance_of(RepositoryImportWorker).to receive(:perform).and_call_original - - expect { project.import_schedule }.to change { project.import_jid } - expect(project.reload.import_status).to eq('finished') - end - end - - describe 'project import state transitions' do - context 'state transition: [:started] => [:finished]' do - let(:after_import_service) { spy(:after_import_service) } - let(:housekeeping_service) { spy(:housekeeping_service) } - - before do - allow(Projects::AfterImportService) - .to receive(:new) { after_import_service } - - allow(after_import_service) - .to receive(:execute) { housekeeping_service.execute } - - allow(Projects::HousekeepingService) - .to receive(:new) { housekeeping_service } - end - - it 'resets project import_error' do - error_message = 'Some error' - mirror = create(:project_empty_repo, :import_started) - mirror.import_state.update(last_error: error_message) - - expect { mirror.import_finish }.to change { mirror.import_error }.from(error_message).to(nil) - end - - it 'performs housekeeping when an import of a fresh project is completed' do - project = create(:project_empty_repo, :import_started, import_type: :github) - - project.import_finish - - expect(after_import_service).to have_received(:execute) - expect(housekeeping_service).to have_received(:execute) - end - - it 'does not perform housekeeping when project repository does not exist' do - project = create(:project, :import_started, import_type: :github) - - project.import_finish - - expect(housekeeping_service).not_to have_received(:execute) - end - - it 'does not perform housekeeping when project does not have a valid import type' do - project = create(:project, :import_started, import_type: nil) - - project.import_finish - - expect(housekeeping_service).not_to have_received(:execute) - end - end - end - describe '#latest_successful_builds_for' do def create_pipeline(status = 'success') create(:ci_pipeline, project: project, @@ -1978,6 +1976,42 @@ describe Project do end end + describe '#import_status' do + context 'with import_state' do + it 'returns the right status' do + project = create(:project, :import_started) + + expect(project.import_status).to eq("started") + end + end + + context 'without import_state' do + it 'returns none' do + project = create(:project) + + expect(project.import_status).to eq('none') + end + end + end + + describe '#human_import_status_name' do + context 'with import_state' do + it 'returns the right human import status' do + project = create(:project, :import_started) + + expect(project.human_import_status_name).to eq('started') + end + end + + context 'without import_state' do + it 'returns none' do + project = create(:project) + + expect(project.human_import_status_name).to eq('none') + end + end + end + describe '#add_import_job' do let(:import_jid) { '123' } @@ -2108,6 +2142,39 @@ describe Project do it 'includes ancestors upto but excluding the given ancestor' do expect(project.ancestors_upto(parent)).to contain_exactly(child2, child) end + + describe 'with hierarchy_order' do + it 'returns ancestors ordered by descending hierarchy' do + expect(project.ancestors_upto(hierarchy_order: :desc)).to eq([parent, child, child2]) + end + + it 'can be used with upto option' do + expect(project.ancestors_upto(parent, hierarchy_order: :desc)).to eq([child, child2]) + end + end + end + + describe '#root_ancestor' do + let(:project) { create(:project) } + + subject { project.root_ancestor } + + it { is_expected.to eq(project.namespace) } + + context 'in a group' do + let(:group) { create(:group) } + let(:project) { create(:project, group: group) } + + it { is_expected.to eq(group) } + end + + context 'in a nested group', :nested_groups do + let(:root) { create(:group) } + let(:child) { create(:group, parent: root) } + let(:project) { create(:project, group: child) } + + it { is_expected.to eq(root) } + end end describe '#lfs_enabled?' do @@ -2720,6 +2787,17 @@ describe Project do end end + describe '#lfs_http_url_to_repo' do + let(:project) { create(:project) } + + it 'returns the url to the repo without a username' do + lfs_http_url_to_repo = project.lfs_http_url_to_repo('operation_that_doesnt_matter') + + expect(lfs_http_url_to_repo).to eq("#{project.web_url}.git") + expect(lfs_http_url_to_repo).not_to include('@') + end + end + describe '#pipeline_status' do let(:project) { create(:project, :repository) } it 'builds a pipeline status' do @@ -3372,7 +3450,7 @@ describe Project do context 'with a ref that is not the default branch' do it 'returns the latest successful pipeline for the given ref' do - expect(project.pipelines).to receive(:latest_successful_for).with('foo') + expect(project.ci_pipelines).to receive(:latest_successful_for).with('foo') project.latest_successful_pipeline_for('foo') end @@ -3400,7 +3478,7 @@ describe Project do it 'memoizes and returns the latest successful pipeline for the default branch' do pipeline = double(:pipeline) - expect(project.pipelines).to receive(:latest_successful_for) + expect(project.ci_pipelines).to receive(:latest_successful_for) .with(project.default_branch) .and_return(pipeline) .once @@ -3414,13 +3492,14 @@ describe Project do describe '#after_import' do let(:project) { create(:project) } + let(:import_state) { create(:import_state, project: project) } it 'runs the correct hooks' do expect(project.repository).to receive(:after_import) expect(project.wiki.repository).to receive(:after_import) - expect(project).to receive(:import_finish) + expect(import_state).to receive(:finish) expect(project).to receive(:update_project_counter_caches) - expect(project).to receive(:remove_import_jid) + expect(import_state).to receive(:remove_jid) expect(project).to receive(:after_create_default_branch) expect(project).to receive(:refresh_markdown_cache!) @@ -3430,6 +3509,10 @@ describe Project do context 'branch protection' do let(:project) { create(:project, :repository) } + before do + create(:import_state, :started, project: project) + end + it 'does not protect when branch protection is disabled' do stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE) @@ -3485,37 +3568,6 @@ describe Project do end end - describe '#remove_import_jid', :clean_gitlab_redis_cache do - let(:project) { } - - context 'without an import JID' do - it 'does nothing' do - project = create(:project) - - expect(Gitlab::SidekiqStatus) - .not_to receive(:unset) - - project.remove_import_jid - end - end - - context 'with an import JID' do - it 'unsets the import JID' do - project = create(:project) - create(:import_state, project: project, jid: '123') - - expect(Gitlab::SidekiqStatus) - .to receive(:unset) - .with('123') - .and_call_original - - project.remove_import_jid - - expect(project.import_jid).to be_nil - end - end - end - describe '#wiki_repository_exists?' do it 'returns true when the wiki repository exists' do project = create(:project, :wiki_repo) @@ -3846,7 +3898,7 @@ describe Project do end context 'with uploads' do - it_behaves_like 'model with mounted uploader', true do + it_behaves_like 'model with uploads', true do let(:model_object) { create(:project, :with_avatar) } let(:upload_attribute) { :avatar } let(:uploader_class) { AttachmentUploader } @@ -4019,6 +4071,27 @@ describe Project do end end + describe '#all_clusters' do + let(:project) { create(:project) } + let(:cluster) { create(:cluster, cluster_type: :project_type, projects: [project]) } + + subject { project.all_clusters } + + it 'returns project level cluster' do + expect(subject).to eq([cluster]) + end + + context 'project belongs to a group' do + let(:group_cluster) { create(:cluster, :group) } + let(:group) { group_cluster.group } + let(:project) { create(:project, group: group) } + + it 'returns clusters for groups of this project' do + expect(subject).to contain_exactly(cluster, group_cluster) + end + end + end + def rugged_config rugged_repo(project.repository).config end diff --git a/spec/models/prometheus_metric_spec.rb b/spec/models/prometheus_metric_spec.rb index a83a31ae88c..3692fe9a559 100644 --- a/spec/models/prometheus_metric_spec.rb +++ b/spec/models/prometheus_metric_spec.rb @@ -6,6 +6,8 @@ describe PrometheusMetric do subject { build(:prometheus_metric) } let(:other_project) { build(:project) } + it_behaves_like 'having unique enum values' + it { is_expected.to belong_to(:project) } it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_presence_of(:query) } diff --git a/spec/models/push_event_payload_spec.rb b/spec/models/push_event_payload_spec.rb index a049ad35584..69a4922b6fd 100644 --- a/spec/models/push_event_payload_spec.rb +++ b/spec/models/push_event_payload_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe PushEventPayload do + it_behaves_like 'having unique enum values' + describe 'saving payloads' do it 'does not allow commit messages longer than 70 characters' do event = create(:push_event) diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb index da61a5f2771..b12ca79847c 100644 --- a/spec/models/remote_mirror_spec.rb +++ b/spec/models/remote_mirror_spec.rb @@ -174,7 +174,15 @@ describe RemoteMirror do end context 'with remote mirroring enabled' do + it 'defaults to disabling only protected branches' do + expect(remote_mirror.only_protected_branches?).to be_falsey + end + context 'with only protected branches enabled' do + before do + remote_mirror.only_protected_branches = true + end + context 'when it did not update in the last minute' do it 'schedules a RepositoryUpdateRemoteMirrorWorker to run now' do expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_async).with(remote_mirror.id, Time.now) diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 187283b284b..f09b4b67061 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1488,6 +1488,7 @@ describe Repository do :size, :commit_count, :rendered_readme, + :readme_path, :contribution_guide, :changelog, :license_blob, @@ -1874,6 +1875,42 @@ describe Repository do end end + describe '#readme_path', :use_clean_rails_memory_store_caching do + context 'with a non-existing repository' do + let(:project) { create(:project) } + + it 'returns nil' do + expect(repository.readme_path).to be_nil + end + end + + context 'with an existing repository' do + context 'when no README exists' do + let(:project) { create(:project, :empty_repo) } + + it 'returns nil' do + expect(repository.readme_path).to be_nil + end + end + + context 'when a README exists' do + let(:project) { create(:project, :repository) } + + it 'returns the README' do + expect(repository.readme_path).to eq("README.md") + end + + it 'caches the response' do + expect(repository).to receive(:readme).and_call_original.once + + 2.times do + expect(repository.readme_path).to eq("README.md") + end + end + end + end + end + describe '#expire_statistics_caches' do it 'expires the caches' do expect(repository).to receive(:expire_method_caches) @@ -2042,9 +2079,10 @@ describe Repository do describe '#refresh_method_caches' do it 'refreshes the caches of the given types' do expect(repository).to receive(:expire_method_caches) - .with(%i(rendered_readme license_blob license_key license)) + .with(%i(rendered_readme readme_path license_blob license_key license)) expect(repository).to receive(:rendered_readme) + expect(repository).to receive(:readme_path) expect(repository).to receive(:license_blob) expect(repository).to receive(:license_key) expect(repository).to receive(:license) diff --git a/spec/models/resource_label_event_spec.rb b/spec/models/resource_label_event_spec.rb index da6e1b5610d..e7e3f7376e6 100644 --- a/spec/models/resource_label_event_spec.rb +++ b/spec/models/resource_label_event_spec.rb @@ -7,6 +7,8 @@ RSpec.describe ResourceLabelEvent, type: :model do let(:issue) { create(:issue) } let(:merge_request) { create(:merge_request) } + it_behaves_like 'having unique enum values' + describe 'associations' do it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:issue) } diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb index 82ff2a002e0..3682e21ca40 100644 --- a/spec/models/todo_spec.rb +++ b/spec/models/todo_spec.rb @@ -236,7 +236,8 @@ describe Todo do create(:todo, target: create(:merge_request)) - expect(described_class.for_target(todo.target)).to eq([todo]) + expect(described_class.for_type(Issue.name).for_target(todo.target)) + .to contain_exactly(todo) end end diff --git a/spec/models/uploads/fog_spec.rb b/spec/models/uploads/fog_spec.rb new file mode 100644 index 00000000000..4a44cf5ab0f --- /dev/null +++ b/spec/models/uploads/fog_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Uploads::Fog do + let(:data_store) { described_class.new } + + before do + stub_uploads_object_storage(FileUploader) + end + + describe '#available?' do + subject { data_store.available? } + + context 'when object storage is enabled' do + it { is_expected.to be_truthy } + end + + context 'when object storage is disabled' do + before do + stub_uploads_object_storage(FileUploader, enabled: false) + end + + it { is_expected.to be_falsy } + end + end + + context 'model with uploads' do + let(:project) { create(:project) } + let(:relation) { project.uploads } + + describe '#keys' do + let!(:uploads) { create_list(:upload, 2, :object_storage, uploader: FileUploader, model: project) } + subject { data_store.keys(relation) } + + it 'returns keys' do + is_expected.to match_array(relation.pluck(:path)) + end + end + + describe '#delete_keys' do + let(:keys) { data_store.keys(relation) } + let!(:uploads) { create_list(:upload, 2, :with_file, :issuable_upload, model: project) } + subject { data_store.delete_keys(keys) } + + before do + uploads.each { |upload| upload.build_uploader.migrate!(2) } + end + + it 'deletes multiple data' do + paths = relation.pluck(:path) + + ::Fog::Storage.new(FileUploader.object_store_credentials).tap do |connection| + paths.each do |path| + expect(connection.get_object('uploads', path)[:body]).not_to be_nil + end + end + + subject + + ::Fog::Storage.new(FileUploader.object_store_credentials).tap do |connection| + paths.each do |path| + expect { connection.get_object('uploads', path)[:body] }.to raise_error(Excon::Error::NotFound) + end + end + end + end + end +end diff --git a/spec/models/uploads/local_spec.rb b/spec/models/uploads/local_spec.rb new file mode 100644 index 00000000000..3468399f370 --- /dev/null +++ b/spec/models/uploads/local_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Uploads::Local do + let(:data_store) { described_class.new } + + before do + stub_uploads_object_storage(FileUploader) + end + + context 'model with uploads' do + let(:project) { create(:project) } + let(:relation) { project.uploads } + + describe '#keys' do + let!(:uploads) { create_list(:upload, 2, uploader: FileUploader, model: project) } + subject { data_store.keys(relation) } + + it 'returns keys' do + is_expected.to match_array(relation.map(&:absolute_path)) + end + end + + describe '#delete_keys' do + let(:keys) { data_store.keys(relation) } + let!(:uploads) { create_list(:upload, 2, :with_file, :issuable_upload, model: project) } + subject { data_store.delete_keys(keys) } + + it 'deletes multiple data' do + paths = relation.map(&:absolute_path) + + paths.each do |path| + expect(File.exist?(path)).to be_truthy + end + + subject + + paths.each do |path| + expect(File.exist?(path)).to be_falsey + end + end + end + end +end diff --git a/spec/models/user_callout_spec.rb b/spec/models/user_callout_spec.rb index 64ba17c81fe..d54355afe12 100644 --- a/spec/models/user_callout_spec.rb +++ b/spec/models/user_callout_spec.rb @@ -3,6 +3,8 @@ require 'rails_helper' describe UserCallout do let!(:callout) { create(:user_callout) } + it_behaves_like 'having unique enum values' + describe 'relationships' do it { is_expected.to belong_to(:user) } end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 7bd6dccd0ad..ff075e65c76 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -4,6 +4,8 @@ describe User do include ProjectForksHelper include TermsHelper + it_behaves_like 'having unique enum values' + describe 'modules' do subject { described_class } @@ -2323,11 +2325,11 @@ describe User do context 'user is member of all groups' do before do - group.add_owner(user) - nested_group_1.add_owner(user) - nested_group_1_1.add_owner(user) - nested_group_2.add_owner(user) - nested_group_2_1.add_owner(user) + group.add_reporter(user) + nested_group_1.add_developer(user) + nested_group_1_1.add_maintainer(user) + nested_group_2.add_developer(user) + nested_group_2_1.add_maintainer(user) end it 'returns all groups' do @@ -3229,7 +3231,7 @@ describe User do end context 'with uploads' do - it_behaves_like 'model with mounted uploader', false do + it_behaves_like 'model with uploads', false do let(:model_object) { create(:user, :with_avatar) } let(:upload_attribute) { :avatar } let(:uploader_class) { AttachmentUploader } diff --git a/spec/policies/note_policy_spec.rb b/spec/policies/note_policy_spec.rb index e8096358f7d..7e25c53e77c 100644 --- a/spec/policies/note_policy_spec.rb +++ b/spec/policies/note_policy_spec.rb @@ -10,11 +10,50 @@ describe NotePolicy, mdoels: true do return @policies if @policies noteable ||= issue - note = create(:note, noteable: noteable, author: user, project: project) + note = if noteable.is_a?(Commit) + create(:note_on_commit, commit_id: noteable.id, author: user, project: project) + else + create(:note, noteable: noteable, author: user, project: project) + end @policies = described_class.new(user, note) end + shared_examples_for 'a discussion with a private noteable' do + let(:noteable) { issue } + let(:policy) { policies(noteable) } + + context 'when the note author can no longer see the noteable' do + it 'can not edit nor read the note' do + expect(policy).to be_disallowed(:admin_note) + expect(policy).to be_disallowed(:resolve_note) + expect(policy).to be_disallowed(:read_note) + end + end + + context 'when the note author can still see the noteable' do + before do + project.add_developer(user) + end + + it 'can edit the note' do + expect(policy).to be_allowed(:admin_note) + expect(policy).to be_allowed(:resolve_note) + expect(policy).to be_allowed(:read_note) + end + end + end + + context 'when the project is private' do + let(:project) { create(:project, :private, :repository) } + + context 'when the noteable is a commit' do + it_behaves_like 'a discussion with a private noteable' do + let(:noteable) { project.repository.head_commit } + end + end + end + context 'when the project is public' do context 'when the note author is not a project member' do it 'can edit a note' do @@ -24,14 +63,48 @@ describe NotePolicy, mdoels: true do end end - context 'when the noteable is a snippet' do + context 'when the noteable is a project snippet' do + it 'can edit note' do + policies = policies(create(:project_snippet, :public, project: project)) + + expect(policies).to be_allowed(:admin_note) + expect(policies).to be_allowed(:resolve_note) + expect(policies).to be_allowed(:read_note) + end + + context 'when it is private' do + it_behaves_like 'a discussion with a private noteable' do + let(:noteable) { create(:project_snippet, :private, project: project) } + end + end + end + + context 'when the noteable is a personal snippet' do it 'can edit note' do - policies = policies(create(:project_snippet, project: project)) + policies = policies(create(:personal_snippet, :public)) expect(policies).to be_allowed(:admin_note) expect(policies).to be_allowed(:resolve_note) expect(policies).to be_allowed(:read_note) end + + context 'when it is private' do + it 'can not edit nor read the note' do + policies = policies(create(:personal_snippet, :private)) + + expect(policies).to be_disallowed(:admin_note) + expect(policies).to be_disallowed(:resolve_note) + expect(policies).to be_disallowed(:read_note) + end + end + end + + context 'when a discussion is confidential' do + before do + issue.update_attribute(:confidential, true) + end + + it_behaves_like 'a discussion with a private noteable' end context 'when a discussion is locked' do diff --git a/spec/presenters/group_member_presenter_spec.rb b/spec/presenters/group_member_presenter_spec.rb index c00e41725d9..bb66523a83d 100644 --- a/spec/presenters/group_member_presenter_spec.rb +++ b/spec/presenters/group_member_presenter_spec.rb @@ -135,4 +135,12 @@ describe GroupMemberPresenter do end end end + + it_behaves_like '#valid_level_roles', :group do + let(:expected_roles) { { 'Developer' => 30, 'Maintainer' => 40, 'Owner' => 50, 'Reporter' => 20 } } + + before do + entity.parent = group + end + end end diff --git a/spec/presenters/project_member_presenter_spec.rb b/spec/presenters/project_member_presenter_spec.rb index 83db5c56cdf..73ef113a1c5 100644 --- a/spec/presenters/project_member_presenter_spec.rb +++ b/spec/presenters/project_member_presenter_spec.rb @@ -135,4 +135,10 @@ describe ProjectMemberPresenter do end end end + + it_behaves_like '#valid_level_roles', :project do + before do + entity.group = group + end + end end diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index 7b0192fa9c8..456de5f1b9a 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -165,32 +165,32 @@ describe ProjectPresenter do describe '#files_anchor_data' do it 'returns files data' do - expect(presenter.files_anchor_data).to have_attributes(enabled: true, - label: 'Files (0 Bytes)', + expect(presenter.files_anchor_data).to have_attributes(is_link: true, + label: a_string_including('0 Bytes'), link: nil) end end describe '#commits_anchor_data' do it 'returns commits data' do - expect(presenter.commits_anchor_data).to have_attributes(enabled: true, - label: 'Commits (0)', + expect(presenter.commits_anchor_data).to have_attributes(is_link: true, + label: a_string_including('0'), link: nil) end end describe '#branches_anchor_data' do it 'returns branches data' do - expect(presenter.branches_anchor_data).to have_attributes(enabled: true, - label: "Branches (0)", + expect(presenter.branches_anchor_data).to have_attributes(is_link: true, + label: a_string_including('0'), link: nil) end end describe '#tags_anchor_data' do it 'returns tags data' do - expect(presenter.tags_anchor_data).to have_attributes(enabled: true, - label: "Tags (0)", + expect(presenter.tags_anchor_data).to have_attributes(is_link: true, + label: a_string_including('0'), link: nil) end end @@ -202,32 +202,32 @@ describe ProjectPresenter do describe '#files_anchor_data' do it 'returns files data' do - expect(presenter.files_anchor_data).to have_attributes(enabled: true, - label: 'Files (0 Bytes)', + expect(presenter.files_anchor_data).to have_attributes(is_link: true, + label: a_string_including('0 Bytes'), link: presenter.project_tree_path(project)) end end describe '#commits_anchor_data' do it 'returns commits data' do - expect(presenter.commits_anchor_data).to have_attributes(enabled: true, - label: 'Commits (0)', + expect(presenter.commits_anchor_data).to have_attributes(is_link: true, + label: a_string_including('0'), link: presenter.project_commits_path(project, project.repository.root_ref)) end end describe '#branches_anchor_data' do it 'returns branches data' do - expect(presenter.branches_anchor_data).to have_attributes(enabled: true, - label: "Branches (#{project.repository.branches.size})", + expect(presenter.branches_anchor_data).to have_attributes(is_link: true, + label: a_string_including("#{project.repository.branches.size}"), link: presenter.project_branches_path(project)) end end describe '#tags_anchor_data' do it 'returns tags data' do - expect(presenter.tags_anchor_data).to have_attributes(enabled: true, - label: "Tags (#{project.repository.tags.size})", + expect(presenter.tags_anchor_data).to have_attributes(is_link: true, + label: a_string_including("#{project.repository.tags.size}"), link: presenter.project_tags_path(project)) end end @@ -236,8 +236,8 @@ describe ProjectPresenter do it 'returns new file data if user can push' do project.add_developer(user) - expect(presenter.new_file_anchor_data).to have_attributes(enabled: false, - label: "New file", + expect(presenter.new_file_anchor_data).to have_attributes(is_link: false, + label: a_string_including("New file"), link: presenter.project_new_blob_path(project, 'master'), class_modifier: 'success') end @@ -264,8 +264,8 @@ describe ProjectPresenter do project.add_developer(user) allow(project.repository).to receive(:readme).and_return(nil) - expect(presenter.readme_anchor_data).to have_attributes(enabled: false, - label: 'Add Readme', + expect(presenter.readme_anchor_data).to have_attributes(is_link: false, + label: a_string_including('Add README'), link: presenter.add_readme_path) end end @@ -274,21 +274,21 @@ describe ProjectPresenter do it 'returns anchor data' do allow(project.repository).to receive(:readme).and_return(double(name: 'readme')) - expect(presenter.readme_anchor_data).to have_attributes(enabled: true, - label: 'Readme', + expect(presenter.readme_anchor_data).to have_attributes(is_link: false, + label: a_string_including('README'), link: presenter.readme_path) end end end describe '#changelog_anchor_data' do - context 'when user can push and CHANGELOG does not exists' do + context 'when user can push and CHANGELOG does not exist' do it 'returns anchor data' do project.add_developer(user) allow(project.repository).to receive(:changelog).and_return(nil) - expect(presenter.changelog_anchor_data).to have_attributes(enabled: false, - label: 'Add Changelog', + expect(presenter.changelog_anchor_data).to have_attributes(is_link: false, + label: a_string_including('Add CHANGELOG'), link: presenter.add_changelog_path) end end @@ -297,21 +297,21 @@ describe ProjectPresenter do it 'returns anchor data' do allow(project.repository).to receive(:changelog).and_return(double(name: 'foo')) - expect(presenter.changelog_anchor_data).to have_attributes(enabled: true, - label: 'Changelog', + expect(presenter.changelog_anchor_data).to have_attributes(is_link: false, + label: a_string_including('CHANGELOG'), link: presenter.changelog_path) end end end describe '#license_anchor_data' do - context 'when user can push and LICENSE does not exists' do + context 'when user can push and LICENSE does not exist' do it 'returns anchor data' do project.add_developer(user) allow(project.repository).to receive(:license_blob).and_return(nil) - expect(presenter.license_anchor_data).to have_attributes(enabled: false, - label: 'Add license', + expect(presenter.license_anchor_data).to have_attributes(is_link: true, + label: a_string_including('Add license'), link: presenter.add_license_path) end end @@ -320,21 +320,21 @@ describe ProjectPresenter do it 'returns anchor data' do allow(project.repository).to receive(:license_blob).and_return(double(name: 'foo')) - expect(presenter.license_anchor_data).to have_attributes(enabled: true, - label: presenter.license_short_name, + expect(presenter.license_anchor_data).to have_attributes(is_link: true, + label: a_string_including(presenter.license_short_name), link: presenter.license_path) end end end describe '#contribution_guide_anchor_data' do - context 'when user can push and CONTRIBUTING does not exists' do + context 'when user can push and CONTRIBUTING does not exist' do it 'returns anchor data' do project.add_developer(user) allow(project.repository).to receive(:contribution_guide).and_return(nil) - expect(presenter.contribution_guide_anchor_data).to have_attributes(enabled: false, - label: 'Add Contribution guide', + expect(presenter.contribution_guide_anchor_data).to have_attributes(is_link: false, + label: a_string_including('Add CONTRIBUTING'), link: presenter.add_contribution_guide_path) end end @@ -343,8 +343,8 @@ describe ProjectPresenter do it 'returns anchor data' do allow(project.repository).to receive(:contribution_guide).and_return(double(name: 'foo')) - expect(presenter.contribution_guide_anchor_data).to have_attributes(enabled: true, - label: 'Contribution guide', + expect(presenter.contribution_guide_anchor_data).to have_attributes(is_link: false, + label: a_string_including('CONTRIBUTING'), link: presenter.contribution_guide_path) end end @@ -355,20 +355,20 @@ describe ProjectPresenter do it 'returns anchor data' do allow(project).to receive(:auto_devops_enabled?).and_return(true) - expect(presenter.autodevops_anchor_data).to have_attributes(enabled: true, - label: 'Auto DevOps enabled', + expect(presenter.autodevops_anchor_data).to have_attributes(is_link: false, + label: a_string_including('Auto DevOps enabled'), link: nil) end end - context 'when user can admin pipeline and CI yml does not exists' do + context 'when user can admin pipeline and CI yml does not exist' do it 'returns anchor data' do project.add_maintainer(user) allow(project).to receive(:auto_devops_enabled?).and_return(false) allow(project.repository).to receive(:gitlab_ci_yml).and_return(nil) - expect(presenter.autodevops_anchor_data).to have_attributes(enabled: false, - label: 'Enable Auto DevOps', + expect(presenter.autodevops_anchor_data).to have_attributes(is_link: false, + label: a_string_including('Enable Auto DevOps'), link: presenter.project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) end end @@ -380,8 +380,8 @@ describe ProjectPresenter do project.add_maintainer(user) cluster = create(:cluster, projects: [project]) - expect(presenter.kubernetes_cluster_anchor_data).to have_attributes(enabled: true, - label: 'Kubernetes configured', + expect(presenter.kubernetes_cluster_anchor_data).to have_attributes(is_link: false, + label: a_string_including('Kubernetes configured'), link: presenter.project_cluster_path(project, cluster)) end @@ -390,16 +390,16 @@ describe ProjectPresenter do create(:cluster, :production_environment, projects: [project]) create(:cluster, projects: [project]) - expect(presenter.kubernetes_cluster_anchor_data).to have_attributes(enabled: true, - label: 'Kubernetes configured', + expect(presenter.kubernetes_cluster_anchor_data).to have_attributes(is_link: false, + label: a_string_including('Kubernetes configured'), link: presenter.project_clusters_path(project)) end it 'returns link to create a cluster if no cluster exists' do project.add_maintainer(user) - expect(presenter.kubernetes_cluster_anchor_data).to have_attributes(enabled: false, - label: 'Add Kubernetes cluster', + expect(presenter.kubernetes_cluster_anchor_data).to have_attributes(is_link: false, + label: a_string_including('Add Kubernetes cluster'), link: presenter.new_project_cluster_path(project)) end end diff --git a/spec/requests/api/applications_spec.rb b/spec/requests/api/applications_spec.rb index 270e12bf201..6154be5c425 100644 --- a/spec/requests/api/applications_spec.rb +++ b/spec/requests/api/applications_spec.rb @@ -25,7 +25,7 @@ describe API::Applications, :api do it 'does not allow creating an application with the wrong redirect_uri format' do expect do - post api('/applications', admin_user), name: 'application_name', redirect_uri: 'wrong_url_format', scopes: '' + post api('/applications', admin_user), name: 'application_name', redirect_uri: 'http://', scopes: '' end.not_to change { Doorkeeper::Application.count } expect(response).to have_gitlab_http_status(400) @@ -33,6 +33,16 @@ describe API::Applications, :api do expect(json_response['message']['redirect_uri'][0]).to eq('must be an absolute URI.') end + it 'does not allow creating an application with a forbidden URI format' do + expect do + post api('/applications', admin_user), name: 'application_name', redirect_uri: 'javascript://alert()', scopes: '' + end.not_to change { Doorkeeper::Application.count } + + expect(response).to have_gitlab_http_status(400) + expect(json_response).to be_a Hash + expect(json_response['message']['redirect_uri'][0]).to eq('is forbidden by the server.') + end + it 'does not allow creating an application without a name' do expect do post api('/applications', admin_user), redirect_uri: 'http://application.url', scopes: '' diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb index cd43bec35df..a43304c9b83 100644 --- a/spec/requests/api/commit_statuses_spec.rb +++ b/spec/requests/api/commit_statuses_spec.rb @@ -16,8 +16,8 @@ describe API::CommitStatuses do let(:get_url) { "/projects/#{project.id}/repository/commits/#{sha}/statuses" } context 'ci commit exists' do - let!(:master) { project.pipelines.create(source: :push, sha: commit.id, ref: 'master', protected: false) } - let!(:develop) { project.pipelines.create(source: :push, sha: commit.id, ref: 'develop', protected: false) } + let!(:master) { project.ci_pipelines.create(source: :push, sha: commit.id, ref: 'master', protected: false) } + let!(:develop) { project.ci_pipelines.create(source: :push, sha: commit.id, ref: 'develop', protected: false) } context "reporter user" do let(:statuses_id) { json_response.map { |status| status['id'] } } diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 329d069ef3d..9e599c2175f 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -818,7 +818,7 @@ describe API::Commits do end context 'when the ref has a pipeline' do - let!(:pipeline) { project.pipelines.create(source: :push, ref: 'master', sha: commit.sha, protected: false) } + let!(:pipeline) { project.ci_pipelines.create(source: :push, ref: 'master', sha: commit.sha, protected: false) } it 'includes a "created" status' do get api(route, current_user) diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index 334dbb1c34c..620f9f5e1d6 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -121,6 +121,13 @@ describe API::Files do end end + context 'when PATs are used' do + it_behaves_like 'repository files' do + let(:token) { create(:personal_access_token, scopes: ['read_repository'], user: user) } + let(:current_user) { { personal_access_token: token } } + end + end + context 'when authenticated', 'as a developer' do it_behaves_like 'repository files' do let(:current_user) { user } @@ -217,6 +224,13 @@ describe API::Files do end end + context 'when PATs are used' do + it_behaves_like 'repository files' do + let(:token) { create(:personal_access_token, scopes: ['read_repository'], user: user) } + let(:current_user) { { personal_access_token: token } } + end + end + context 'when unauthenticated', 'and project is private' do it_behaves_like '404 response' do let(:request) { get api(route(file_path)), params } @@ -317,6 +331,21 @@ describe API::Files do let(:request) { get api(route(file_path), guest), params } end end + + context 'when PATs are used' do + it 'returns file by commit sha' do + token = create(:personal_access_token, scopes: ['read_repository'], user: user) + + # This file is deleted on HEAD + file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee" + params[:ref] = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9" + expect(Gitlab::Workhorse).to receive(:send_git_blob) + + get api(route(file_path) + "/raw", personal_access_token: token), params + + expect(response).to have_gitlab_http_status(200) + end + end end describe "POST /projects/:id/repository/files/:file_path" do @@ -362,6 +391,24 @@ describe API::Files do expect(response).to have_gitlab_http_status(400) end + context 'with PATs' do + it 'returns 403 with `read_repository` scope' do + token = create(:personal_access_token, scopes: ['read_repository'], user: user) + + post api(route(file_path), personal_access_token: token), params + + expect(response).to have_gitlab_http_status(403) + end + + it 'returns 201 with `api` scope' do + token = create(:personal_access_token, scopes: ['api'], user: user) + + post api(route(file_path), personal_access_token: token), params + + expect(response).to have_gitlab_http_status(201) + end + end + context "when specifying an author" do it "creates a new file with the specified author" do params.merge!(author_email: author_email, author_name: author_name) diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index cca449e9e56..f7916441313 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -5,7 +5,6 @@ require_relative '../../../config/initializers/sentry' describe API::Helpers do include API::APIGuard::HelperMethods include described_class - include SentryHelper include TermsHelper let(:user) { create(:user) } @@ -206,13 +205,33 @@ describe API::Helpers do expect { current_user }.to raise_error Gitlab::Auth::ExpiredError end + + context 'when impersonation is disabled' do + let(:personal_access_token) { create(:personal_access_token, :impersonation, user: user) } + + before do + stub_config_setting(impersonation_enabled: false) + env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + end + + it 'does not allow impersonation tokens' do + expect { current_user }.to raise_error Gitlab::Auth::ImpersonationDisabled + end + end end end describe '.handle_api_exception' do before do - allow_any_instance_of(self.class).to receive(:sentry_enabled?).and_return(true) allow_any_instance_of(self.class).to receive(:rack_response) + allow(Gitlab::Sentry).to receive(:enabled?).and_return(true) + + stub_application_setting( + sentry_enabled: true, + sentry_dsn: "dummy://12345:67890@sentry.localdomain/sentry/42" + ) + configure_sentry + Raven.client.configuration.encoding = 'json' end it 'does not report a MethodNotAllowed exception to Sentry' do @@ -228,10 +247,13 @@ describe API::Helpers do exception = RuntimeError.new('test error') allow(exception).to receive(:backtrace).and_return(caller) - expect_any_instance_of(self.class).to receive(:sentry_context) - expect(Raven).to receive(:capture_exception).with(exception, extra: {}) + expect(Raven).to receive(:capture_exception).with(exception, tags: { + correlation_id: 'new-correlation-id' + }, extra: {}) - handle_api_exception(exception) + Gitlab::CorrelationId.use_id('new-correlation-id') do + handle_api_exception(exception) + end end context 'with a personal access token given' do @@ -242,7 +264,6 @@ describe API::Helpers do # We need to stub at a lower level than #sentry_enabled? otherwise # Sentry is not enabled when the request below is made, and the test # would pass even without the fix - expect(Gitlab::Sentry).to receive(:enabled?).twice.and_return(true) expect(ProjectsFinder).to receive(:new).and_raise('Runtime Error!') get api('/projects', personal_access_token: token) @@ -259,17 +280,7 @@ describe API::Helpers do # Sentry events are an array of the form [auth_header, data, options] let(:event_data) { Raven.client.transport.events.first[1] } - before do - stub_application_setting( - sentry_enabled: true, - sentry_dsn: "dummy://12345:67890@sentry.localdomain/sentry/42" - ) - configure_sentry - Raven.client.configuration.encoding = 'json' - end - it 'sends the params, excluding confidential values' do - expect(Gitlab::Sentry).to receive(:enabled?).twice.and_return(true) expect(ProjectsFinder).to receive(:new).and_raise('Runtime Error!') get api('/projects', user), password: 'dont_send_this', other_param: 'send_this' diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb index 93e1c3a2294..bb32d581176 100644 --- a/spec/requests/api/members_spec.rb +++ b/spec/requests/api/members_spec.rb @@ -224,6 +224,37 @@ describe API::Members do end end + context 'access levels' do + it 'does not create the member if group level is higher', :nested_groups do + parent = create(:group) + + group.update(parent: parent) + project.update(group: group) + parent.add_developer(stranger) + + post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), + user_id: stranger.id, access_level: Member::REPORTER + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']['access_level']).to eq(["should be higher than Developer inherited membership from group #{parent.name}"]) + end + + it 'creates the member if group level is lower', :nested_groups do + parent = create(:group) + + group.update(parent: parent) + project.update(group: group) + parent.add_developer(stranger) + + post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), + user_id: stranger.id, access_level: Member::MAINTAINER + + expect(response).to have_gitlab_http_status(201) + expect(json_response['id']).to eq(stranger.id) + expect(json_response['access_level']).to eq(Member::MAINTAINER) + end + end + it "returns 409 if member already exists" do post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), user_id: maintainer.id, access_level: Member::MAINTAINER diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index e4e0ca285e0..27bcde77860 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -359,6 +359,8 @@ describe API::MergeRequests do expect(json_response['should_close_merge_request']).to be_falsy expect(json_response['force_close_merge_request']).to be_falsy expect(json_response['changes_count']).to eq(merge_request.merge_request_diff.real_size) + expect(json_response['merge_error']).to eq(merge_request.merge_error) + expect(json_response).not_to include('rebase_in_progress') end it 'exposes description and title html when render_html is true' do @@ -369,6 +371,14 @@ describe API::MergeRequests do expect(json_response).to include('title_html', 'description_html') end + it 'exposes rebase_in_progress when include_rebase_in_progress is true' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), include_rebase_in_progress: true + + expect(response).to have_gitlab_http_status(200) + + expect(json_response).to include('rebase_in_progress') + end + context 'merge_request_metrics' do before do merge_request.metrics.update!(merged_by: user, @@ -1181,6 +1191,26 @@ describe API::MergeRequests do end end + describe 'PUT :id/merge_requests/:merge_request_iid/rebase' do + it 'enqueues a rebase of the merge request against the target branch' do + Sidekiq::Testing.fake! do + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/rebase", user) + end + + expect(response).to have_gitlab_http_status(202) + expect(RebaseWorker.jobs.size).to eq(1) + end + + it 'returns 403 if the user cannot push to the branch' do + guest = create(:user) + project.add_guest(guest) + + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/rebase", guest) + + expect(response).to have_gitlab_http_status(403) + end + end + describe 'Time tracking' do let(:issuable) { merge_request } diff --git a/spec/requests/api/namespaces_spec.rb b/spec/requests/api/namespaces_spec.rb index e2000ab42e8..145356c4df5 100644 --- a/spec/requests/api/namespaces_spec.rb +++ b/spec/requests/api/namespaces_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe API::Namespaces do let(:admin) { create(:admin) } let(:user) { create(:user) } - let!(:group1) { create(:group) } + let!(:group1) { create(:group, name: 'group.one') } let!(:group2) { create(:group, :nested) } describe "GET /namespaces" do diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index 638cc9767d4..2e4fa0f9e16 100644 --- a/spec/requests/api/pipelines_spec.rb +++ b/spec/requests/api/pipelines_spec.rb @@ -304,7 +304,7 @@ describe API::Pipelines do it 'creates and returns a new pipeline' do expect do post api("/projects/#{project.id}/pipeline", user), ref: project.default_branch - end.to change { project.pipelines.count }.by(1) + end.to change { project.ci_pipelines.count }.by(1) expect(response).to have_gitlab_http_status(201) expect(json_response).to be_a Hash @@ -317,8 +317,8 @@ describe API::Pipelines do it 'creates and returns a new pipeline using the given variables' do expect do post api("/projects/#{project.id}/pipeline", user), ref: project.default_branch, variables: variables - end.to change { project.pipelines.count }.by(1) - expect_variables(project.pipelines.last.variables, variables) + end.to change { project.ci_pipelines.count }.by(1) + expect_variables(project.ci_pipelines.last.variables, variables) expect(response).to have_gitlab_http_status(201) expect(json_response).to be_a Hash @@ -338,8 +338,8 @@ describe API::Pipelines do it 'creates and returns a new pipeline using the given variables' do expect do post api("/projects/#{project.id}/pipeline", user), ref: project.default_branch, variables: variables - end.to change { project.pipelines.count }.by(1) - expect_variables(project.pipelines.last.variables, variables) + end.to change { project.ci_pipelines.count }.by(1) + expect_variables(project.ci_pipelines.last.variables, variables) expect(response).to have_gitlab_http_status(201) expect(json_response).to be_a Hash @@ -353,7 +353,7 @@ describe API::Pipelines do it "doesn't create a job" do expect do post api("/projects/#{project.id}/pipeline", user), ref: project.default_branch - end.not_to change { project.pipelines.count } + end.not_to change { project.ci_pipelines.count } expect(response).to have_gitlab_http_status(400) end diff --git a/spec/requests/api/project_import_spec.rb b/spec/requests/api/project_import_spec.rb index c8fa4754810..204702b8a5a 100644 --- a/spec/requests/api/project_import_spec.rb +++ b/spec/requests/api/project_import_spec.rb @@ -42,7 +42,7 @@ describe API::ProjectImport do end it 'does not schedule an import for a namespace that does not exist' do - expect_any_instance_of(Project).not_to receive(:import_schedule) + expect_any_instance_of(ProjectImportState).not_to receive(:schedule) expect(::Projects::CreateService).not_to receive(:new) post api('/projects/import', user), namespace: 'nonexistent', path: 'test-import2', file: fixture_file_upload(file) @@ -52,7 +52,7 @@ describe API::ProjectImport do end it 'does not schedule an import if the user has no permission to the namespace' do - expect_any_instance_of(Project).not_to receive(:import_schedule) + expect_any_instance_of(ProjectImportState).not_to receive(:schedule) post(api('/projects/import', create(:user)), path: 'test-import3', @@ -64,7 +64,7 @@ describe API::ProjectImport do end it 'does not schedule an import if the user uploads no valid file' do - expect_any_instance_of(Project).not_to receive(:import_schedule) + expect_any_instance_of(ProjectImportState).not_to receive(:schedule) post api('/projects/import', user), path: 'test-import3', file: './random/test' @@ -119,7 +119,7 @@ describe API::ProjectImport do let(:existing_project) { create(:project, namespace: user.namespace) } it 'does not schedule an import' do - expect_any_instance_of(Project).not_to receive(:import_schedule) + expect_any_instance_of(ProjectImportState).not_to receive(:schedule) post api('/projects/import', user), path: existing_project.path, file: fixture_file_upload(file) @@ -139,7 +139,7 @@ describe API::ProjectImport do end def stub_import(namespace) - expect_any_instance_of(Project).to receive(:import_schedule) + expect_any_instance_of(ProjectImportState).to receive(:schedule) expect(::Projects::CreateService).to receive(:new).with(user, hash_including(namespace_id: namespace.id)).and_call_original end end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 62b6a3ce42e..e40db55cd20 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1906,7 +1906,7 @@ describe API::Projects do let(:group) { create(:group) } let(:group2) do group = create(:group, name: 'group2_name') - group.add_owner(user2) + group.add_maintainer(user2) group end diff --git a/spec/requests/api/triggers_spec.rb b/spec/requests/api/triggers_spec.rb index 0ae6796d1e4..658df6945d2 100644 --- a/spec/requests/api/triggers_spec.rb +++ b/spec/requests/api/triggers_spec.rb @@ -39,7 +39,7 @@ describe API::Triggers do end context 'Have a commit' do - let(:pipeline) { project.pipelines.last } + let(:pipeline) { project.ci_pipelines.last } it 'creates pipeline' do post api("/projects/#{project.id}/trigger/pipeline"), options.merge(ref: 'master') diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index c71eae9164a..0dc459d9b5a 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -302,7 +302,7 @@ describe 'Git HTTP requests' do it 'rejects pushes with 403 Forbidden' do upload(path, env) do |response| expect(response).to have_gitlab_http_status(:forbidden) - expect(response.body).to eq(change_access_error(:push_code)) + expect(response.body).to eq('You are not allowed to push code to this project.') end end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index bdfb12dc5df..5c3b37ef11c 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -36,36 +36,33 @@ describe 'project routing' do shared_examples 'RESTful project resources' do let(:actions) { [:index, :create, :new, :edit, :show, :update, :destroy] } let(:controller_path) { controller } - let(:id) { { id: '1' } } - let(:format) { {} } # response format, e.g. { format: :html } - let(:params) { { namespace_id: 'gitlab', project_id: 'gitlabhq' } } it 'to #index' do - expect(get("/gitlab/gitlabhq/#{controller_path}")).to route_to("projects/#{controller}#index", params) if actions.include?(:index) + expect(get("/gitlab/gitlabhq/#{controller_path}")).to route_to("projects/#{controller}#index", namespace_id: 'gitlab', project_id: 'gitlabhq') if actions.include?(:index) end it 'to #create' do - expect(post("/gitlab/gitlabhq/#{controller_path}")).to route_to("projects/#{controller}#create", params) if actions.include?(:create) + expect(post("/gitlab/gitlabhq/#{controller_path}")).to route_to("projects/#{controller}#create", namespace_id: 'gitlab', project_id: 'gitlabhq') if actions.include?(:create) end it 'to #new' do - expect(get("/gitlab/gitlabhq/#{controller_path}/new")).to route_to("projects/#{controller}#new", params) if actions.include?(:new) + expect(get("/gitlab/gitlabhq/#{controller_path}/new")).to route_to("projects/#{controller}#new", namespace_id: 'gitlab', project_id: 'gitlabhq') if actions.include?(:new) end it 'to #edit' do - expect(get("/gitlab/gitlabhq/#{controller_path}/1/edit")).to route_to("projects/#{controller}#edit", params.merge(**id, **format)) if actions.include?(:edit) + expect(get("/gitlab/gitlabhq/#{controller_path}/1/edit")).to route_to("projects/#{controller}#edit", namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1') if actions.include?(:edit) end it 'to #show' do - expect(get("/gitlab/gitlabhq/#{controller_path}/1")).to route_to("projects/#{controller}#show", params.merge(**id, **format)) if actions.include?(:show) + expect(get("/gitlab/gitlabhq/#{controller_path}/1")).to route_to("projects/#{controller}#show", namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1') if actions.include?(:show) end it 'to #update' do - expect(put("/gitlab/gitlabhq/#{controller_path}/1")).to route_to("projects/#{controller}#update", params.merge(id)) if actions.include?(:update) + expect(put("/gitlab/gitlabhq/#{controller_path}/1")).to route_to("projects/#{controller}#update", namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1') if actions.include?(:update) end it 'to #destroy' do - expect(delete("/gitlab/gitlabhq/#{controller_path}/1")).to route_to("projects/#{controller}#destroy", params.merge(**id, **format)) if actions.include?(:destroy) + expect(delete("/gitlab/gitlabhq/#{controller_path}/1")).to route_to("projects/#{controller}#destroy", namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1') if actions.include?(:destroy) end end @@ -154,13 +151,12 @@ describe 'project routing' do end it 'to #history' do - expect(get('/gitlab/gitlabhq/wikis/1/history')).to route_to('projects/wikis#history', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: :html) + expect(get('/gitlab/gitlabhq/wikis/1/history')).to route_to('projects/wikis#history', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1') end it_behaves_like 'RESTful project resources' do let(:actions) { [:create, :edit, :show, :destroy] } let(:controller) { 'wikis' } - let(:format) { { format: :html } } end end diff --git a/spec/rubocop/cop/migration/add_reference_spec.rb b/spec/rubocop/cop/migration/add_reference_spec.rb index 8f795bb561e..c348fc0efac 100644 --- a/spec/rubocop/cop/migration/add_reference_spec.rb +++ b/spec/rubocop/cop/migration/add_reference_spec.rb @@ -29,7 +29,7 @@ describe RuboCop::Cop::Migration::AddReference do expect_offense(<<~RUBY) call do add_reference(:projects, :users) - ^^^^^^^^^^^^^ `add_reference` requires `index: true` + ^^^^^^^^^^^^^ `add_reference` requires `index: true` or `index: { options... }` end RUBY end @@ -38,7 +38,7 @@ describe RuboCop::Cop::Migration::AddReference do expect_offense(<<~RUBY) def up add_reference(:projects, :users, index: false) - ^^^^^^^^^^^^^ `add_reference` requires `index: true` + ^^^^^^^^^^^^^ `add_reference` requires `index: true` or `index: { options... }` end RUBY end @@ -50,5 +50,13 @@ describe RuboCop::Cop::Migration::AddReference do end RUBY end + + it 'does not register an offense when the index is unique' do + expect_no_offenses(<<~RUBY) + def up + add_reference(:projects, :users, index: { unique: true } ) + end + RUBY + end end end diff --git a/spec/serializers/diff_file_entity_spec.rb b/spec/serializers/diff_file_entity_spec.rb index 7497b8f27bd..073c13c2cbb 100644 --- a/spec/serializers/diff_file_entity_spec.rb +++ b/spec/serializers/diff_file_entity_spec.rb @@ -13,39 +13,6 @@ describe DiffFileEntity do subject { entity.as_json } - shared_examples 'diff file entity' do - it 'exposes correct attributes' do - expect(subject).to include( - :submodule, :submodule_link, :submodule_tree_url, :file_path, - :deleted_file, :old_path, :new_path, :mode_changed, - :a_mode, :b_mode, :text, :old_path_html, - :new_path_html, :highlighted_diff_lines, :parallel_diff_lines, - :blob, :file_hash, :added_lines, :removed_lines, :diff_refs, :content_sha, - :stored_externally, :external_storage, :too_large, :collapsed, :new_file, - :context_lines_path - ) - end - - it 'includes viewer' do - expect(subject[:viewer].with_indifferent_access) - .to match_schema('entities/diff_viewer') - end - - # Converted diff files from GitHub import does not contain blob file - # and content sha. - context 'when diff file does not have a blob and content sha' do - it 'exposes some attributes as nil' do - allow(diff_file).to receive(:content_sha).and_return(nil) - allow(diff_file).to receive(:blob).and_return(nil) - - expect(subject[:context_lines_path]).to be_nil - expect(subject[:view_path]).to be_nil - expect(subject[:highlighted_diff_lines]).to be_nil - expect(subject[:can_modify_blob]).to be_nil - end - end - end - context 'when there is no merge request' do it_behaves_like 'diff file entity' end diff --git a/spec/serializers/discussion_diff_file_entity_spec.rb b/spec/serializers/discussion_diff_file_entity_spec.rb new file mode 100644 index 00000000000..101ac918a98 --- /dev/null +++ b/spec/serializers/discussion_diff_file_entity_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe DiscussionDiffFileEntity do + include RepoHelpers + + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:commit) { project.commit(sample_commit.id) } + let(:diff_refs) { commit.diff_refs } + let(:diff) { commit.raw_diffs.first } + let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } + let(:entity) { described_class.new(diff_file, request: {}) } + + subject { entity.as_json } + + context 'when there is no merge request' do + it_behaves_like 'diff file discussion entity' + end + + context 'when there is a merge request' do + let(:user) { create(:user) } + let(:request) { EntityRequest.new(project: project, current_user: user) } + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:entity) { described_class.new(diff_file, request: request, merge_request: merge_request) } + + it_behaves_like 'diff file discussion entity' + + it 'exposes additional attributes' do + expect(subject).to include(:edit_path) + end + + it 'exposes no diff lines' do + expect(subject).not_to include(:highlighted_diff_lines, + :parallel_diff_lines) + end + end +end diff --git a/spec/serializers/discussion_entity_spec.rb b/spec/serializers/discussion_entity_spec.rb index 0590304e832..138749b0fdf 100644 --- a/spec/serializers/discussion_entity_spec.rb +++ b/spec/serializers/discussion_entity_spec.rb @@ -74,13 +74,5 @@ describe DiscussionEntity do :active ) end - - context 'when diff file is a image' do - it 'exposes image attributes' do - allow(discussion).to receive(:on_image?).and_return(true) - - expect(subject.keys).to include(:image_diff_html) - end - end end end diff --git a/spec/serializers/issue_board_entity_spec.rb b/spec/serializers/issue_board_entity_spec.rb new file mode 100644 index 00000000000..06d9d3657e6 --- /dev/null +++ b/spec/serializers/issue_board_entity_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe IssueBoardEntity do + let(:project) { create(:project) } + let(:resource) { create(:issue, project: project) } + let(:user) { create(:user) } + + let(:request) { double('request', current_user: user) } + + subject { described_class.new(resource, request: request).as_json } + + it 'has basic attributes' do + expect(subject).to include(:id, :iid, :title, :confidential, :due_date, :project_id, :relative_position, + :project, :labels) + end + + it 'has path and endpoints' do + expect(subject).to include(:reference_path, :real_path, :issue_sidebar_endpoint, + :toggle_subscription_endpoint, :assignable_labels_endpoint) + end +end diff --git a/spec/serializers/issue_serializer_spec.rb b/spec/serializers/issue_serializer_spec.rb index 75578816e75..e8c46c0cdee 100644 --- a/spec/serializers/issue_serializer_spec.rb +++ b/spec/serializers/issue_serializer_spec.rb @@ -24,4 +24,12 @@ describe IssueSerializer do expect(json_entity).to match_schema('entities/issue_sidebar') end end + + context 'board issue serialization' do + let(:serializer) { 'board' } + + it 'matches board issue json schema' do + expect(json_entity).to match_schema('entities/issue_board') + end + end end diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb index e67d12b7a89..774486dcb6d 100644 --- a/spec/serializers/pipeline_entity_spec.rb +++ b/spec/serializers/pipeline_entity_spec.rb @@ -44,7 +44,7 @@ describe PipelineEntity do expect(subject).to include :flags expect(subject[:flags]) .to include :latest, :stuck, :auto_devops, - :yaml_errors, :retryable, :cancelable + :yaml_errors, :retryable, :cancelable, :merge_request end end diff --git a/spec/services/ci/archive_trace_service_spec.rb b/spec/services/ci/archive_trace_service_spec.rb new file mode 100644 index 00000000000..8e9cb65f3bc --- /dev/null +++ b/spec/services/ci/archive_trace_service_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe Ci::ArchiveTraceService, '#execute' do + subject { described_class.new.execute(job) } + + context 'when job is finished' do + let(:job) { create(:ci_build, :success, :trace_live) } + + it 'creates an archived trace' do + expect { subject }.not_to raise_error + + expect(job.reload.job_artifacts_trace).to be_exist + end + + context 'when trace is already archived' do + let!(:job) { create(:ci_build, :success, :trace_artifact) } + + it 'ignores an exception' do + expect { subject }.not_to raise_error + end + + it 'does not create an archived trace' do + expect { subject }.not_to change { Ci::JobArtifact.trace.count } + end + end + end + + context 'when job is running' do + let(:job) { create(:ci_build, :running, :trace_live) } + + it 'increments Prometheus counter, sends crash report to Sentry and ignore an error for continuing to archive' do + expect(Gitlab::Sentry) + .to receive(:track_exception) + .with(::Gitlab::Ci::Trace::ArchiveError, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/51502', + extra: { job_id: job.id } ).once + + expect(Rails.logger) + .to receive(:error) + .with("Failed to archive trace. id: #{job.id} message: Job is not finished yet") + .and_call_original + + expect(Gitlab::Metrics) + .to receive(:counter) + .with(:job_trace_archive_failed_total, "Counter of failed attempts of trace archiving") + .and_call_original + + expect { subject }.not_to raise_error + end + end +end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 4d9c5aabbda..ccc6b0ef1c7 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -18,7 +18,8 @@ describe Ci::CreatePipelineService do message: 'Message', ref: ref_name, trigger_request: nil, - variables_attributes: nil) + variables_attributes: nil, + merge_request: nil) params = { ref: ref, before: '00000000', after: after, @@ -26,7 +27,7 @@ describe Ci::CreatePipelineService do variables_attributes: variables_attributes } described_class.new(project, user, params).execute( - source, trigger_request: trigger_request) + source, trigger_request: trigger_request, merge_request: merge_request) end context 'valid params' do @@ -43,7 +44,7 @@ describe Ci::CreatePipelineService do expect(pipeline).to be_valid expect(pipeline).to be_persisted expect(pipeline).to be_push - expect(pipeline).to eq(project.pipelines.last) + expect(pipeline).to eq(project.ci_pipelines.last) expect(pipeline).to have_attributes(user: user) expect(pipeline).to have_attributes(status: 'pending') expect(pipeline.repository_source?).to be true @@ -60,10 +61,10 @@ describe Ci::CreatePipelineService do context 'when merge requests already exist for this source branch' do let(:merge_request_1) do - create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project) + create(:merge_request, source_branch: 'feature', target_branch: "master", source_project: project) end let(:merge_request_2) do - create(:merge_request, source_branch: 'master', target_branch: "branch_2", source_project: project) + create(:merge_request, source_branch: 'feature', target_branch: "v1.1.0", source_project: project) end context 'when related merge request is already merged' do @@ -83,7 +84,7 @@ describe Ci::CreatePipelineService do merge_request_1 merge_request_2 - head_pipeline = execute_service + head_pipeline = execute_service(ref: 'feature', after: nil) expect(merge_request_1.reload.head_pipeline).to eq(head_pipeline) expect(merge_request_2.reload.head_pipeline).to eq(head_pipeline) @@ -123,12 +124,12 @@ describe Ci::CreatePipelineService do let!(:target_project) { create(:project, :repository) } it 'updates head pipeline for merge request' do - merge_request = create(:merge_request, source_branch: 'master', - target_branch: "branch_1", + merge_request = create(:merge_request, source_branch: 'feature', + target_branch: "master", source_project: project, target_project: target_project) - head_pipeline = execute_service + head_pipeline = execute_service(ref: 'feature', after: nil) expect(merge_request.reload.head_pipeline).to eq(head_pipeline) end @@ -656,5 +657,244 @@ describe Ci::CreatePipelineService do end end end + + describe 'Merge request pipelines' do + let(:pipeline) do + execute_service(source: source, merge_request: merge_request, ref: ref_name) + end + + before do + stub_ci_pipeline_yaml_file(YAML.dump(config)) + end + + let(:ref_name) { 'feature' } + + context 'when source is merge request' do + let(:source) { :merge_request } + + context "when config has merge_requests keywords" do + let(:config) do + { + build: { + stage: 'build', + script: 'echo' + }, + test: { + stage: 'test', + script: 'echo', + only: ['merge_requests'] + }, + pages: { + stage: 'deploy', + script: 'echo', + except: ['merge_requests'] + } + } + end + + context 'when merge request is specified' do + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: ref_name, + target_project: project, + target_branch: 'master') + end + + it 'creates a merge request pipeline' do + expect(pipeline).to be_persisted + expect(pipeline).to be_merge_request + expect(pipeline.merge_request).to eq(merge_request) + expect(pipeline.builds.order(:stage_id).map(&:name)).to eq(%w[test]) + end + + context 'when ref is tag' do + let(:ref_name) { 'v1.1.0' } + + it 'does not create a merge request pipeline' do + expect(pipeline).not_to be_persisted + expect(pipeline.errors[:tag]).to eq(["is not included in the list"]) + end + end + + context 'when merge request is created from a forked project' do + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: ref_name, + target_project: target_project, + target_branch: 'master') + end + + let!(:project) { fork_project(target_project, nil, repository: true) } + let!(:target_project) { create(:project, :repository) } + + it 'creates a merge request pipeline in the forked project' do + expect(pipeline).to be_persisted + expect(project.ci_pipelines).to eq([pipeline]) + expect(target_project.ci_pipelines).to be_empty + end + end + + context "when there are no matched jobs" do + let(:config) do + { + test: { + stage: 'test', + script: 'echo', + except: ['merge_requests'] + } + } + end + + it 'does not create a merge request pipeline' do + expect(pipeline).not_to be_persisted + expect(pipeline.errors[:base]).to eq(["No stages / jobs for this pipeline."]) + end + end + end + + context 'when merge request is not specified' do + let(:merge_request) { nil } + + it 'does not create a merge request pipeline' do + expect(pipeline).not_to be_persisted + expect(pipeline.errors[:merge_request]).to eq(["can't be blank"]) + end + end + end + + context "when config does not have merge_requests keywords" do + let(:config) do + { + build: { + stage: 'build', + script: 'echo' + }, + test: { + stage: 'test', + script: 'echo' + }, + pages: { + stage: 'deploy', + script: 'echo' + } + } + end + + context 'when merge request is specified' do + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: ref_name, + target_project: project, + target_branch: 'master') + end + + it 'does not create a merge request pipeline' do + expect(pipeline).not_to be_persisted + + expect(pipeline.errors[:base]) + .to eq(['No stages / jobs for this pipeline.']) + end + end + + context 'when merge request is not specified' do + let(:merge_request) { nil } + + it 'does not create a merge request pipeline' do + expect(pipeline).not_to be_persisted + + expect(pipeline.errors[:base]) + .to eq(['No stages / jobs for this pipeline.']) + end + end + end + end + + context 'when source is web' do + let(:source) { :web } + + context "when config has merge_requests keywords" do + let(:config) do + { + build: { + stage: 'build', + script: 'echo' + }, + test: { + stage: 'test', + script: 'echo', + only: ['merge_requests'] + }, + pages: { + stage: 'deploy', + script: 'echo', + except: ['merge_requests'] + } + } + end + + context 'when merge request is specified' do + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: ref_name, + target_project: project, + target_branch: 'master') + end + + it 'does not create a merge request pipeline' do + expect(pipeline).not_to be_persisted + expect(pipeline.errors[:merge_request]).to eq(["must be blank"]) + end + end + + context 'when merge request is not specified' do + let(:merge_request) { nil } + + it 'creates a branch pipeline' do + expect(pipeline).to be_persisted + expect(pipeline).to be_web + expect(pipeline.merge_request).to be_nil + expect(pipeline.builds.order(:stage_id).map(&:name)).to eq(%w[build pages]) + end + end + end + end + end + end + + describe '#execute!' do + subject { service.execute!(*args) } + + let(:service) { described_class.new(project, user, ref: ref_name) } + let(:args) { [:push] } + + context 'when user has a permission to create a pipeline' do + let(:user) { create(:user) } + + before do + project.add_developer(user) + end + + it 'does not raise an error' do + expect { subject }.not_to raise_error + end + + it 'creates a pipeline' do + expect { subject }.to change { Ci::Pipeline.count }.by(1) + end + end + + context 'when user does not have a permission to create a pipeline' do + let(:user) { create(:user) } + + it 'raises an error' do + expect { subject } + .to raise_error(described_class::CreateError) + .with_message('Insufficient permissions to create a new pipeline') + end + end end end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index e779675744c..87185891470 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -20,9 +20,9 @@ describe Ci::RetryBuildService do CLONE_ACCESSORS = described_class::CLONE_ACCESSORS REJECT_ACCESSORS = - %i[id status user token coverage trace runner artifacts_expire_at - artifacts_file artifacts_metadata artifacts_size created_at - updated_at started_at finished_at queued_at erased_by + %i[id status user token token_encrypted coverage trace runner + artifacts_expire_at artifacts_file artifacts_metadata artifacts_size + created_at updated_at started_at finished_at queued_at erased_by erased_at auto_canceled_by job_artifacts job_artifacts_archive job_artifacts_metadata job_artifacts_trace job_artifacts_junit job_artifacts_sast job_artifacts_dependency_scanning diff --git a/spec/services/clusters/gcp/finalize_creation_service_spec.rb b/spec/services/clusters/gcp/finalize_creation_service_spec.rb index efee158739d..d69678c1277 100644 --- a/spec/services/clusters/gcp/finalize_creation_service_spec.rb +++ b/spec/services/clusters/gcp/finalize_creation_service_spec.rb @@ -19,6 +19,10 @@ describe Clusters::Gcp::FinalizeCreationService, '#execute' do subject { described_class.new.execute(provider) } + before do + allow(ClusterPlatformConfigureWorker).to receive(:perform_async) + end + shared_examples 'success' do it 'configures provider and kubernetes' do subject @@ -39,14 +43,10 @@ describe Clusters::Gcp::FinalizeCreationService, '#execute' do expect(platform.token).to eq(token) end - it 'creates kubernetes namespace model' do - subject + it 'calls ClusterPlatformConfigureWorker in a ascync fashion' do + expect(ClusterPlatformConfigureWorker).to receive(:perform_async).with(cluster.id) - kubernetes_namespace = cluster.reload.kubernetes_namespace - expect(kubernetes_namespace).to be_persisted - expect(kubernetes_namespace.namespace).to eq(namespace) - expect(kubernetes_namespace.service_account_name).to eq("#{namespace}-service-account") - expect(kubernetes_namespace.service_account_token).to be_present + subject end end @@ -104,8 +104,10 @@ describe Clusters::Gcp::FinalizeCreationService, '#execute' do stub_kubeclient_discover(api_url) stub_kubeclient_get_namespace(api_url) stub_kubeclient_create_namespace(api_url) + stub_kubeclient_get_service_account_error(api_url, 'gitlab') stub_kubeclient_create_service_account(api_url) stub_kubeclient_create_secret(api_url) + stub_kubeclient_put_secret(api_url, 'gitlab-token') stub_kubeclient_get_secret( api_url, @@ -115,19 +117,6 @@ describe Clusters::Gcp::FinalizeCreationService, '#execute' do namespace: 'default' } ) - - stub_kubeclient_get_namespace(api_url, namespace: namespace) - stub_kubeclient_create_service_account(api_url, namespace: namespace) - stub_kubeclient_create_secret(api_url, namespace: namespace) - - stub_kubeclient_get_secret( - api_url, - { - metadata_name: "#{namespace}-token", - token: Base64.encode64(token), - namespace: namespace - } - ) end end @@ -155,8 +144,8 @@ describe Clusters::Gcp::FinalizeCreationService, '#execute' do before do provider.legacy_abac = false + stub_kubeclient_get_cluster_role_binding_error(api_url, 'gitlab-admin') stub_kubeclient_create_cluster_role_binding(api_url) - stub_kubeclient_create_role_binding(api_url, namespace: namespace) end include_context 'kubernetes information successfully fetched' diff --git a/spec/services/clusters/gcp/kubernetes/create_or_update_namespace_service_spec.rb b/spec/services/clusters/gcp/kubernetes/create_or_update_namespace_service_spec.rb index 661364ac765..fe785735fef 100644 --- a/spec/services/clusters/gcp/kubernetes/create_or_update_namespace_service_spec.rb +++ b/spec/services/clusters/gcp/kubernetes/create_or_update_namespace_service_spec.rb @@ -10,6 +10,7 @@ describe Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService, '#execute' d let(:api_url) { 'https://kubernetes.example.com' } let(:project) { cluster.project } let(:cluster_project) { cluster.cluster_project } + let(:namespace) { "#{project.path}-#{project.id}" } subject do described_class.new( @@ -18,40 +19,31 @@ describe Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService, '#execute' d ).execute end - shared_context 'kubernetes requests' do - before do - stub_kubeclient_discover(api_url) - stub_kubeclient_get_namespace(api_url) - stub_kubeclient_create_service_account(api_url) - stub_kubeclient_create_secret(api_url) - - stub_kubeclient_get_namespace(api_url, namespace: namespace) - stub_kubeclient_create_service_account(api_url, namespace: namespace) - stub_kubeclient_create_secret(api_url, namespace: namespace) - - stub_kubeclient_get_secret( - api_url, - { - metadata_name: "#{namespace}-token", - token: Base64.encode64('sample-token'), - namespace: namespace - } - ) - end + before do + stub_kubeclient_discover(api_url) + stub_kubeclient_get_namespace(api_url) + stub_kubeclient_get_service_account_error(api_url, 'gitlab') + stub_kubeclient_create_service_account(api_url) + stub_kubeclient_get_secret_error(api_url, 'gitlab-token') + stub_kubeclient_create_secret(api_url) + + stub_kubeclient_get_namespace(api_url, namespace: namespace) + stub_kubeclient_get_service_account_error(api_url, "#{namespace}-service-account", namespace: namespace) + stub_kubeclient_create_service_account(api_url, namespace: namespace) + stub_kubeclient_create_secret(api_url, namespace: namespace) + stub_kubeclient_put_secret(api_url, "#{namespace}-token", namespace: namespace) + + stub_kubeclient_get_secret( + api_url, + { + metadata_name: "#{namespace}-token", + token: Base64.encode64('sample-token'), + namespace: namespace + } + ) end - context 'when kubernetes namespace is not persisted' do - let(:namespace) { "#{project.path}-#{project.id}" } - - let(:kubernetes_namespace) do - create(:cluster_kubernetes_namespace, - cluster: cluster, - project: cluster_project.project, - cluster_project: cluster_project) - end - - include_context 'kubernetes requests' - + shared_examples 'successful creation of kubernetes namespace' do it 'creates a Clusters::KubernetesNamespace' do expect do subject @@ -59,7 +51,7 @@ describe Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService, '#execute' d end it 'creates project service account' do - expect_any_instance_of(Clusters::Gcp::Kubernetes::CreateServiceAccountService).to receive(:execute).once + expect_any_instance_of(Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService).to receive(:execute).once subject end @@ -74,42 +66,69 @@ describe Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService, '#execute' d end end - context 'when there is a Kubernetes Namespace associated' do - let(:namespace) { 'new-namespace' } + context 'group clusters' do + let(:cluster) { create(:cluster, :group, :provided_by_gcp) } + let(:group) { cluster.group } + let(:project) { create(:project, group: group) } + + context 'when kubernetes namespace is not persisted' do + let(:kubernetes_namespace) do + build(:cluster_kubernetes_namespace, + cluster: cluster, + project: project) + end - let(:kubernetes_namespace) do - create(:cluster_kubernetes_namespace, - cluster: cluster, - project: cluster_project.project, - cluster_project: cluster_project) + it_behaves_like 'successful creation of kubernetes namespace' end + end - include_context 'kubernetes requests' + context 'project clusters' do + context 'when kubernetes namespace is not persisted' do + let(:kubernetes_namespace) do + build(:cluster_kubernetes_namespace, + cluster: cluster, + project: cluster_project.project, + cluster_project: cluster_project) + end - before do - platform.update_column(:namespace, 'new-namespace') + it_behaves_like 'successful creation of kubernetes namespace' end - it 'does not create any Clusters::KubernetesNamespace' do - subject + context 'when there is a Kubernetes Namespace associated' do + let(:namespace) { 'new-namespace' } - expect(cluster.kubernetes_namespace).to eq(kubernetes_namespace) - end + let(:kubernetes_namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + project: cluster_project.project, + cluster_project: cluster_project) + end - it 'creates project service account' do - expect_any_instance_of(Clusters::Gcp::Kubernetes::CreateServiceAccountService).to receive(:execute).once + before do + platform.update_column(:namespace, 'new-namespace') + end - subject - end + it 'does not create any Clusters::KubernetesNamespace' do + subject - it 'updates Clusters::KubernetesNamespace' do - subject + expect(cluster.kubernetes_namespace).to eq(kubernetes_namespace) + end - kubernetes_namespace.reload + it 'creates project service account' do + expect_any_instance_of(Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService).to receive(:execute).once - expect(kubernetes_namespace.namespace).to eq(namespace) - expect(kubernetes_namespace.service_account_name).to eq("#{namespace}-service-account") - expect(kubernetes_namespace.encrypted_service_account_token).to be_present + subject + end + + it 'updates Clusters::KubernetesNamespace' do + subject + + kubernetes_namespace.reload + + expect(kubernetes_namespace.namespace).to eq(namespace) + expect(kubernetes_namespace.service_account_name).to eq("#{namespace}-service-account") + expect(kubernetes_namespace.encrypted_service_account_token).to be_present + end end end end diff --git a/spec/services/clusters/gcp/kubernetes/create_service_account_service_spec.rb b/spec/services/clusters/gcp/kubernetes/create_or_update_service_account_service_spec.rb index 588edff85d4..11a65d0c300 100644 --- a/spec/services/clusters/gcp/kubernetes/create_service_account_service_spec.rb +++ b/spec/services/clusters/gcp/kubernetes/create_or_update_service_account_service_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -describe Clusters::Gcp::Kubernetes::CreateServiceAccountService do +describe Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService do include KubernetesHelpers let(:api_url) { 'http://111.111.111.111' } @@ -55,7 +55,11 @@ describe Clusters::Gcp::Kubernetes::CreateServiceAccountService do before do stub_kubeclient_discover(api_url) stub_kubeclient_get_namespace(api_url, namespace: namespace) - stub_kubeclient_create_service_account(api_url, namespace: namespace ) + + stub_kubeclient_get_service_account_error(api_url, service_account_name, namespace: namespace) + stub_kubeclient_create_service_account(api_url, namespace: namespace) + + stub_kubeclient_get_secret_error(api_url, token_name, namespace: namespace) stub_kubeclient_create_secret(api_url, namespace: namespace) end @@ -74,10 +78,12 @@ describe Clusters::Gcp::Kubernetes::CreateServiceAccountService do context 'with RBAC cluster' do let(:rbac) { true } + let(:cluster_role_binding_name) { 'gitlab-admin' } before do cluster.platform_kubernetes.rbac! + stub_kubeclient_get_cluster_role_binding_error(api_url, cluster_role_binding_name) stub_kubeclient_create_cluster_role_binding(api_url) end @@ -130,10 +136,12 @@ describe Clusters::Gcp::Kubernetes::CreateServiceAccountService do context 'With RBAC enabled cluster' do let(:rbac) { true } + let(:role_binding_name) { "gitlab-#{namespace}"} before do cluster.platform_kubernetes.rbac! + stub_kubeclient_get_role_binding_error(api_url, role_binding_name, namespace: namespace) stub_kubeclient_create_role_binding(api_url, namespace: namespace) end diff --git a/spec/services/clusters/refresh_service_spec.rb b/spec/services/clusters/refresh_service_spec.rb new file mode 100644 index 00000000000..58ab3c3cf73 --- /dev/null +++ b/spec/services/clusters/refresh_service_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::RefreshService do + shared_examples 'creates a kubernetes namespace' do + let(:token) { 'aaaaaa' } + let(:service_account_creator) { double(Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService, execute: true) } + let(:secrets_fetcher) { double(Clusters::Gcp::Kubernetes::FetchKubernetesTokenService, execute: token) } + + it 'creates a kubernetes namespace' do + expect(Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService).to receive(:namespace_creator).and_return(service_account_creator) + expect(Clusters::Gcp::Kubernetes::FetchKubernetesTokenService).to receive(:new).and_return(secrets_fetcher) + + expect { subject }.to change(project.kubernetes_namespaces, :count) + + kubernetes_namespace = cluster.kubernetes_namespaces.first + expect(kubernetes_namespace).to be_present + expect(kubernetes_namespace.project).to eq(project) + end + end + + shared_examples 'does not create a kubernetes namespace' do + it 'does not create a new kubernetes namespace' do + expect(Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService).not_to receive(:namespace_creator) + expect(Clusters::Gcp::Kubernetes::FetchKubernetesTokenService).not_to receive(:new) + + expect { subject }.not_to change(Clusters::KubernetesNamespace, :count) + end + end + + describe '.create_or_update_namespaces_for_cluster' do + let(:cluster) { create(:cluster, :provided_by_user, :project) } + let(:project) { cluster.project } + + subject { described_class.create_or_update_namespaces_for_cluster(cluster) } + + context 'cluster is project level' do + include_examples 'creates a kubernetes namespace' + + context 'when project already has kubernetes namespace' do + before do + create(:cluster_kubernetes_namespace, project: project, cluster: cluster) + end + + include_examples 'does not create a kubernetes namespace' + end + end + + context 'cluster is group level' do + let(:cluster) { create(:cluster, :provided_by_user, :group) } + let(:group) { cluster.group } + let(:project) { create(:project, group: group) } + + include_examples 'creates a kubernetes namespace' + + context 'when project already has kubernetes namespace' do + before do + create(:cluster_kubernetes_namespace, project: project, cluster: cluster) + end + + include_examples 'does not create a kubernetes namespace' + end + end + end + + describe '.create_or_update_namespaces_for_project' do + let(:project) { create(:project) } + + subject { described_class.create_or_update_namespaces_for_project(project) } + + it 'creates no kubernetes namespaces' do + expect { subject }.not_to change(project.kubernetes_namespaces, :count) + end + + context 'project has a project cluster' do + let!(:cluster) { create(:cluster, :provided_by_gcp, cluster_type: :project_type, projects: [project]) } + + include_examples 'creates a kubernetes namespace' + + context 'when project already has kubernetes namespace' do + before do + create(:cluster_kubernetes_namespace, project: project, cluster: cluster) + end + + include_examples 'does not create a kubernetes namespace' + end + end + + context 'project belongs to a group cluster' do + let!(:cluster) { create(:cluster, :provided_by_gcp, :group) } + + let(:group) { cluster.group } + let(:project) { create(:project, group: group) } + + include_examples 'creates a kubernetes namespace' + + context 'when project already has kubernetes namespace' do + before do + create(:cluster_kubernetes_namespace, project: project, cluster: cluster) + end + + include_examples 'does not create a kubernetes namespace' + end + end + end +end diff --git a/spec/services/files/multi_service_spec.rb b/spec/services/files/multi_service_spec.rb index 5f3c8e82715..84c48d63c64 100644 --- a/spec/services/files/multi_service_spec.rb +++ b/spec/services/files/multi_service_spec.rb @@ -122,26 +122,47 @@ describe Files::MultiService do let(:action) { 'move' } let(:new_file_path) { 'files/ruby/new_popen.rb' } + let(:result) { subject.execute } + let(:blob) { repository.blob_at_branch(branch_name, new_file_path) } + context 'when original file has been updated' do before do update_file(original_file_path) end it 'rejects the commit' do - results = subject.execute - - expect(results[:status]).to eq(:error) - expect(results[:message]).to match(original_file_path) + expect(result[:status]).to eq(:error) + expect(result[:message]).to match(original_file_path) end end - context 'when original file have not been updated' do + context 'when original file has not been updated' do it 'moves the file' do - results = subject.execute - blob = project.repository.blob_at_branch(branch_name, new_file_path) - - expect(results[:status]).to eq(:success) + expect(result[:status]).to eq(:success) expect(blob).to be_present + expect(blob.data).to eq(file_content) + end + + context 'when content is nil' do + let(:file_content) { nil } + + it 'moves the existing content untouched' do + original_content = repository.blob_at_branch(branch_name, original_file_path).data + + expect(result[:status]).to eq(:success) + expect(blob).to be_present + expect(blob.data).to eq(original_content) + end + end + + context 'when content is an empty string' do + let(:file_content) { '' } + + it 'moves the file and empties it' do + expect(result[:status]).to eq(:success) + expect(blob).not_to be_nil + expect(blob.data).to eq('') + end end end end diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 74bcc15f912..5a3ecb1019b 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -159,6 +159,78 @@ describe MergeRequests::CreateService do end end end + + describe 'Merge request pipelines' do + before do + stub_ci_pipeline_yaml_file(YAML.dump(config)) + end + + context "when .gitlab-ci.yml has merge_requests keywords" do + let(:config) do + { + test: { + stage: 'test', + script: 'echo', + only: ['merge_requests'] + } + } + end + + it 'creates a merge request pipeline and sets it as a head pipeline' do + expect(merge_request).to be_persisted + + merge_request.reload + expect(merge_request.merge_request_pipelines.count).to eq(1) + expect(merge_request.actual_head_pipeline).to be_merge_request + end + + context "when branch pipeline was created before a merge request pipline has been created" do + before do + create(:ci_pipeline, project: merge_request.source_project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + tag: false) + + merge_request + end + + it 'sets the latest merge request pipeline as the head pipeline' do + expect(merge_request.actual_head_pipeline).to be_merge_request + end + end + + context "when the 'ci_merge_request_pipeline' feature flag is disabled" do + before do + stub_feature_flags(ci_merge_request_pipeline: false) + end + + it 'does not create a merge request pipeline' do + expect(merge_request).to be_persisted + + merge_request.reload + expect(merge_request.merge_request_pipelines.count).to eq(0) + end + end + end + + context "when .gitlab-ci.yml does not have merge_requests keywords" do + let(:config) do + { + test: { + stage: 'test', + script: 'echo' + } + } + end + + it 'does not create a merge request pipeline' do + expect(merge_request).to be_persisted + + merge_request.reload + expect(merge_request.merge_request_pipelines.count).to eq(0) + end + end + end end it_behaves_like 'new issuable record that supports quick actions' do diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 61c6ba7d550..1d9c75dedce 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -132,6 +132,94 @@ describe MergeRequests::RefreshService do end end + describe 'Merge request pipelines' do + before do + stub_ci_pipeline_yaml_file(YAML.dump(config)) + end + + subject { service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/master') } + + context "when .gitlab-ci.yml has merge_requests keywords" do + let(:config) do + { + test: { + stage: 'test', + script: 'echo', + only: ['merge_requests'] + } + } + end + + it 'create merge request pipeline' do + expect { subject } + .to change { @merge_request.merge_request_pipelines.count }.by(1) + .and change { @fork_merge_request.merge_request_pipelines.count }.by(1) + .and change { @another_merge_request.merge_request_pipelines.count }.by(1) + end + + context "when branch pipeline was created before a merge request pipline has been created" do + before do + create(:ci_pipeline, project: @merge_request.source_project, + sha: @merge_request.diff_head_sha, + ref: @merge_request.source_branch, + tag: false) + + subject + end + + it 'sets the latest merge request pipeline as a head pipeline' do + @merge_request.reload + expect(@merge_request.actual_head_pipeline).to be_merge_request + end + + it 'returns pipelines in correct order' do + @merge_request.reload + expect(@merge_request.all_pipelines.first).to be_merge_request + expect(@merge_request.all_pipelines.second).to be_push + end + end + + context "when MergeRequestUpdateWorker is retried by an exception" do + it 'does not re-create a duplicate merge request pipeline' do + expect do + service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/master') + end.to change { @merge_request.merge_request_pipelines.count }.by(1) + + expect do + service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/master') + end.not_to change { @merge_request.merge_request_pipelines.count } + end + end + + context "when the 'ci_merge_request_pipeline' feature flag is disabled" do + before do + stub_feature_flags(ci_merge_request_pipeline: false) + end + + it 'does not create a merge request pipeline' do + expect { subject } + .not_to change { @merge_request.merge_request_pipelines.count } + end + end + end + + context "when .gitlab-ci.yml does not have merge_requests keywords" do + let(:config) do + { + test: { + stage: 'test', + script: 'echo' + } + } + end + + it 'does not create a merge request pipeline' do + expect { subject } + .not_to change { @merge_request.merge_request_pipelines.count } + end + end + end + context 'push to origin repo source branch when an MR was reopened' do let(:refresh_service) { service.new(@project, @user) } let(:notification_service) { spy('notification_service') } @@ -533,4 +621,77 @@ describe MergeRequests::RefreshService do @fork_build_failed_todo.reload end end + + describe 'updating merge_commit' do + let(:service) { described_class.new(project, user) } + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + let(:oldrev) { TestEnv::BRANCH_SHA['merge-commit-analyze-before'] } + let(:newrev) { TestEnv::BRANCH_SHA['merge-commit-analyze-after'] } # Pretend branch is now updated + + let!(:merge_request) do + create( + :merge_request, + source_project: project, + source_branch: 'merge-commit-analyze-after', + target_branch: 'merge-commit-analyze-before', + target_project: project, + merge_user: user + ) + end + + let!(:merge_request_side_branch) do + create( + :merge_request, + source_project: project, + source_branch: 'merge-commit-analyze-side-branch', + target_branch: 'merge-commit-analyze-before', + target_project: project, + merge_user: user + ) + end + + subject { service.execute(oldrev, newrev, 'refs/heads/merge-commit-analyze-before') } + + context 'feature enabled' do + before do + stub_feature_flags(branch_push_merge_commit_analyze: true) + end + + it "updates merge requests' merge_commits" do + expect(Gitlab::BranchPushMergeCommitAnalyzer).to receive(:new).and_wrap_original do |original_method, commits| + expect(commits.map(&:id)).to eq(%w{646ece5cfed840eca0a4feb21bcd6a81bb19bda3 29284d9bcc350bcae005872d0be6edd016e2efb5 5f82584f0a907f3b30cfce5bb8df371454a90051 8a994512e8c8f0dfcf22bb16df6e876be7a61036 689600b91aabec706e657e38ea706ece1ee8268f db46a1c5a5e474aa169b6cdb7a522d891bc4c5f9}) + + original_method.call(commits) + end + + subject + + merge_request.reload + merge_request_side_branch.reload + + expect(merge_request.merge_commit.id).to eq('646ece5cfed840eca0a4feb21bcd6a81bb19bda3') + expect(merge_request_side_branch.merge_commit.id).to eq('29284d9bcc350bcae005872d0be6edd016e2efb5') + end + end + + context 'when feature is disabled' do + before do + stub_feature_flags(branch_push_merge_commit_analyze: false) + end + + it "does not trigger analysis" do + expect(Gitlab::BranchPushMergeCommitAnalyzer).not_to receive(:new) + + subject + + merge_request.reload + merge_request_side_branch.reload + + expect(merge_request.merge_commit).to eq(nil) + expect(merge_request_side_branch.merge_commit).to eq(nil) + end + end + end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 2d8da7673dc..0f6c2604984 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -2146,6 +2146,27 @@ describe NotificationService, :mailer do end end + describe 'Repository cleanup' do + let(:user) { create(:user) } + let(:project) { create(:project) } + + describe '#repository_cleanup_success' do + it 'emails the specified user only' do + notification.repository_cleanup_success(project, user) + + should_email(user) + end + end + + describe '#repository_cleanup_failure' do + it 'emails the specified user only' do + notification.repository_cleanup_failure(project, user, 'Some error') + + should_email(user) + end + end + end + def build_team(project) @u_watcher = create_global_setting_for(create(:user), :watch) @u_participating = create_global_setting_for(create(:user), :participating) diff --git a/spec/services/projects/cleanup_service_spec.rb b/spec/services/projects/cleanup_service_spec.rb new file mode 100644 index 00000000000..3d4587ce2a1 --- /dev/null +++ b/spec/services/projects/cleanup_service_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Projects::CleanupService do + let(:project) { create(:project, :repository, bfg_object_map: fixture_file_upload('spec/fixtures/bfg_object_map.txt')) } + let(:object_map) { project.bfg_object_map } + + subject(:service) { described_class.new(project) } + + describe '#execute' do + it 'runs the apply_bfg_object_map gitaly RPC' do + expect_next_instance_of(Gitlab::Git::RepositoryCleaner) do |cleaner| + expect(cleaner).to receive(:apply_bfg_object_map).with(kind_of(IO)) + end + + service.execute + end + + it 'runs garbage collection on the repository' do + expect_next_instance_of(GitGarbageCollectWorker) do |worker| + expect(worker).to receive(:perform) + end + + service.execute + end + + it 'clears the repository cache' do + expect(project.repository).to receive(:expire_all_method_caches) + + service.execute + end + + it 'removes the object map file' do + service.execute + + expect(object_map.exists?).to be_falsy + end + + it 'raises an error if no object map can be found' do + object_map.remove! + + expect { service.execute }.to raise_error(described_class::NoUploadError) + end + end +end diff --git a/spec/services/projects/create_from_template_service_spec.rb b/spec/services/projects/create_from_template_service_spec.rb index 141ccf7c4d8..da078dd36c6 100644 --- a/spec/services/projects/create_from_template_service_spec.rb +++ b/spec/services/projects/create_from_template_service_spec.rb @@ -47,7 +47,7 @@ describe Projects::CreateFromTemplateService do end it 'is not scheduled' do - expect(project.import_scheduled?).to be(false) + expect(project.import_scheduled?).to be_nil end it 'repository is empty' do diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 08de27ca44a..f71e2b4bc24 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -261,6 +261,32 @@ describe Projects::CreateService, '#execute' do end end + context 'when group has kubernetes cluster' do + let(:group_cluster) { create(:cluster, :group, :provided_by_gcp) } + let(:group) { group_cluster.group } + + let(:token) { 'aaaa' } + let(:service_account_creator) { double(Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService, execute: true) } + let(:secrets_fetcher) { double(Clusters::Gcp::Kubernetes::FetchKubernetesTokenService, execute: token) } + + before do + group.add_owner(user) + + expect(Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService).to receive(:namespace_creator).and_return(service_account_creator) + expect(Clusters::Gcp::Kubernetes::FetchKubernetesTokenService).to receive(:new).and_return(secrets_fetcher) + end + + it 'creates kubernetes namespace for the project' do + project = create_project(user, opts.merge!(namespace_id: group.id)) + + expect(project).to be_valid + + kubernetes_namespace = group_cluster.kubernetes_namespaces.first + expect(kubernetes_namespace).to be_present + expect(kubernetes_namespace.project).to eq(project) + end + end + context 'when there is an active service template' do before do create(:service, project: nil, template: true, active: true) diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index 2e07d4f8013..132ad9a2646 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -62,6 +62,32 @@ describe Projects::TransferService do expect(rugged_config['gitlab.fullpath']).to eq "#{group.full_path}/#{project.path}" end + + context 'new group has a kubernetes cluster' do + let(:group_cluster) { create(:cluster, :group, :provided_by_gcp) } + let(:group) { group_cluster.group } + + let(:token) { 'aaaa' } + let(:service_account_creator) { double(Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService, execute: true) } + let(:secrets_fetcher) { double(Clusters::Gcp::Kubernetes::FetchKubernetesTokenService, execute: token) } + + subject { transfer_project(project, user, group) } + + before do + expect(Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService).to receive(:namespace_creator).and_return(service_account_creator) + expect(Clusters::Gcp::Kubernetes::FetchKubernetesTokenService).to receive(:new).and_return(secrets_fetcher) + end + + it 'creates kubernetes namespace for the project' do + subject + + expect(project.kubernetes_namespaces.count).to eq(1) + + kubernetes_namespace = group_cluster.kubernetes_namespaces.first + expect(kubernetes_namespace).to be_present + expect(kubernetes_namespace.project).to eq(project) + end + end end context 'when transfer fails' do diff --git a/spec/support/active_record_enum.rb b/spec/support/active_record_enum.rb new file mode 100644 index 00000000000..fb1189c7f17 --- /dev/null +++ b/spec/support/active_record_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +shared_examples 'having unique enum values' do + described_class.defined_enums.each do |name, enum| + it "has unique values in #{name.inspect}" do + duplicated = enum.group_by(&:last).select { |key, value| value.size > 1 } + + expect(duplicated).to be_empty, + "Duplicated values detected: #{duplicated.values.map(&Hash.method(:[]))}" + end + end +end diff --git a/spec/support/controllers/sessionless_auth_controller_shared_examples.rb b/spec/support/controllers/sessionless_auth_controller_shared_examples.rb new file mode 100644 index 00000000000..7e4958f177a --- /dev/null +++ b/spec/support/controllers/sessionless_auth_controller_shared_examples.rb @@ -0,0 +1,92 @@ +shared_examples 'authenticates sessionless user' do |path, format, params| + params ||= {} + + before do + stub_authentication_activity_metrics(debug: false) + end + + let(:user) { create(:user) } + let(:personal_access_token) { create(:personal_access_token, user: user) } + let(:default_params) { { format: format }.merge(params.except(:public) || {}) } + + context "when the 'personal_access_token' param is populated with the personal access token" do + it 'logs the user in' do + expect(authentication_metrics) + .to increment(:user_authenticated_counter) + .and increment(:user_session_override_counter) + .and increment(:user_sessionless_authentication_counter) + + get path, default_params.merge(private_token: personal_access_token.token) + + expect(response).to have_gitlab_http_status(200) + expect(controller.current_user).to eq(user) + end + + it 'does not log the user in if page is public', if: params[:public] do + get path, default_params + + expect(response).to have_gitlab_http_status(200) + expect(controller.current_user).to be_nil + end + end + + context 'when the personal access token has no api scope', unless: params[:public] do + it 'does not log the user in' do + expect(authentication_metrics) + .to increment(:user_unauthenticated_counter) + + personal_access_token.update(scopes: [:read_user]) + + get path, default_params.merge(private_token: personal_access_token.token) + + expect(response).not_to have_gitlab_http_status(200) + end + end + + context "when the 'PERSONAL_ACCESS_TOKEN' header is populated with the personal access token" do + it 'logs the user in' do + expect(authentication_metrics) + .to increment(:user_authenticated_counter) + .and increment(:user_session_override_counter) + .and increment(:user_sessionless_authentication_counter) + + @request.headers['PRIVATE-TOKEN'] = personal_access_token.token + get path, default_params + + expect(response).to have_gitlab_http_status(200) + end + end + + context "when the 'feed_token' param is populated with the feed token", if: format == :rss do + it "logs the user in" do + expect(authentication_metrics) + .to increment(:user_authenticated_counter) + .and increment(:user_session_override_counter) + .and increment(:user_sessionless_authentication_counter) + + get path, default_params.merge(feed_token: user.feed_token) + + expect(response).to have_gitlab_http_status 200 + end + end + + context "when the 'feed_token' param is populated with an invalid feed token", if: format == :rss, unless: params[:public] do + it "logs the user" do + expect(authentication_metrics) + .to increment(:user_unauthenticated_counter) + + get path, default_params.merge(feed_token: 'token') + + expect(response.status).not_to eq 200 + end + end + + it "doesn't log the user in otherwise", unless: params[:public] do + expect(authentication_metrics) + .to increment(:user_unauthenticated_counter) + + get path, default_params.merge(private_token: 'token') + + expect(response.status).not_to eq(200) + end +end diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb index 18cf08f0b9e..922f3df144d 100644 --- a/spec/support/features/discussion_comments_shared_example.rb +++ b/spec/support/features/discussion_comments_shared_example.rb @@ -142,6 +142,14 @@ shared_examples 'discussion comments' do |resource_name| find(comments_selector, match: :first) end + def submit_reply(text) + find("#{comments_selector} .js-vue-discussion-reply").click + find("#{comments_selector} .note-textarea").send_keys(text) + + click_button "Comment" + wait_for_requests + end + it 'clicking "Start discussion" will post a discussion' do new_comment = all(comments_selector).last @@ -149,16 +157,29 @@ shared_examples 'discussion comments' do |resource_name| expect(new_comment).to have_selector '.discussion' end + if resource_name =~ /(issue|merge request)/ + it 'can be replied to' do + submit_reply('some text') + + expect(page).to have_css('.discussion-notes .note', count: 2) + expect(page).to have_content 'Collapse replies' + end + + it 'can be collapsed' do + submit_reply('another text') + + find('.js-collapse-replies').click + expect(page).to have_css('.discussion-notes .note', count: 1) + expect(page).to have_content '1 reply' + end + end + if resource_name == 'merge request' let(:note_id) { find("#{comments_selector} .note:first-child", match: :first)['data-note-id'] } let(:reply_id) { find("#{comments_selector} .note:last-child", match: :first)['data-note-id'] } it 'shows resolved discussion when toggled' do - find("#{comments_selector} .js-vue-discussion-reply").click - find("#{comments_selector} .note-textarea").send_keys('a') - - click_button "Comment" - wait_for_requests + submit_reply('a') click_button "Resolve discussion" wait_for_requests diff --git a/spec/support/helpers/features/list_rows_helpers.rb b/spec/support/helpers/features/list_rows_helpers.rb new file mode 100644 index 00000000000..0626415361c --- /dev/null +++ b/spec/support/helpers/features/list_rows_helpers.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# These helpers allow you to access rows in the list +# +# Usage: +# describe "..." do +# include Spec::Support::Helpers::Features::ListRowsHelpers +# ... +# +# expect(first_row.text).to include("John Doe") +# expect(second_row.text).to include("John Smith") +# +module Spec + module Support + module Helpers + module Features + module ListRowsHelpers + def first_row + page.all('ul.content-list > li')[0] + end + + def second_row + page.all('ul.content-list > li')[1] + end + end + end + end + end +end diff --git a/spec/support/helpers/features/sorting_helpers.rb b/spec/support/helpers/features/sorting_helpers.rb index ad0053ec5cf..003ecb251fe 100644 --- a/spec/support/helpers/features/sorting_helpers.rb +++ b/spec/support/helpers/features/sorting_helpers.rb @@ -13,9 +13,9 @@ module Spec module Features module SortingHelpers def sort_by(value) - find('button.dropdown-toggle').click + find('.filter-dropdown-container .dropdown').click - page.within('.content ul.dropdown-menu.dropdown-menu-right li') do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link(value) end end diff --git a/spec/support/helpers/git_http_helpers.rb b/spec/support/helpers/git_http_helpers.rb index b8289e6c5f1..9a5845af90c 100644 --- a/spec/support/helpers/git_http_helpers.rb +++ b/spec/support/helpers/git_http_helpers.rb @@ -60,9 +60,4 @@ module GitHttpHelpers message = Gitlab::GitAccessWiki::ERROR_MESSAGES[error_key] message || raise("GitAccessWiki error message key '#{error_key}' not found") end - - def change_access_error(error_key) - message = Gitlab::Checks::ChangeAccess::ERROR_MESSAGES[error_key] - message || raise("ChangeAccess error message key '#{error_key}' not found") - end end diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb index ccaf86aa3a6..39bd305d88a 100644 --- a/spec/support/helpers/kubernetes_helpers.rb +++ b/spec/support/helpers/kubernetes_helpers.rb @@ -34,6 +34,17 @@ module KubernetesHelpers WebMock.stub_request(:get, deployments_url).to_return(response || kube_deployments_response) end + def stub_kubeclient_knative_services(**options) + options[:name] ||= "kubetest" + options[:namespace] ||= "default" + options[:domain] ||= "example.com" + + stub_kubeclient_discover(service.api_url) + knative_url = service.api_url + "/apis/serving.knative.dev/v1alpha1/services" + + WebMock.stub_request(:get, knative_url).to_return(kube_response(kube_knative_services_body(options))) + end + def stub_kubeclient_get_secret(api_url, **options) options[:metadata_name] ||= "default-token-1" options[:namespace] ||= "default" @@ -47,6 +58,11 @@ module KubernetesHelpers .to_return(status: [status, "Internal Server Error"]) end + def stub_kubeclient_get_service_account_error(api_url, name, namespace: 'default', status: 404) + WebMock.stub_request(:get, api_url + "/api/v1/namespaces/#{namespace}/serviceaccounts/#{name}") + .to_return(status: [status, "Internal Server Error"]) + end + def stub_kubeclient_create_service_account(api_url, namespace: 'default') WebMock.stub_request(:post, api_url + "/api/v1/namespaces/#{namespace}/serviceaccounts") .to_return(kube_response({})) @@ -62,11 +78,26 @@ module KubernetesHelpers .to_return(kube_response({})) end + def stub_kubeclient_put_secret(api_url, name, namespace: 'default') + WebMock.stub_request(:put, api_url + "/api/v1/namespaces/#{namespace}/secrets/#{name}") + .to_return(kube_response({})) + end + + def stub_kubeclient_get_cluster_role_binding_error(api_url, name, status: 404) + WebMock.stub_request(:get, api_url + "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/#{name}") + .to_return(status: [status, "Internal Server Error"]) + end + def stub_kubeclient_create_cluster_role_binding(api_url) WebMock.stub_request(:post, api_url + '/apis/rbac.authorization.k8s.io/v1/clusterrolebindings') .to_return(kube_response({})) end + def stub_kubeclient_get_role_binding_error(api_url, name, namespace: 'default', status: 404) + WebMock.stub_request(:get, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{name}") + .to_return(status: [status, "Internal Server Error"]) + end + def stub_kubeclient_create_role_binding(api_url, namespace: 'default') WebMock.stub_request(:post, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings") .to_return(kube_response({})) @@ -161,6 +192,13 @@ module KubernetesHelpers } end + def kube_knative_services_body(**options) + { + "kind" => "List", + "items" => [kube_service(options)] + } + end + # This is a partial response, it will have many more elements in reality but # these are the ones we care about at the moment def kube_pod(name: "kube-pod", app: "valid-pod-label", status: "Running", track: nil) @@ -204,6 +242,54 @@ module KubernetesHelpers } end + def kube_service(name: "kubetest", namespace: "default", domain: "example.com") + { + "metadata" => { + "creationTimestamp" => "2018-11-21T06:16:33Z", + "name" => name, + "namespace" => namespace, + "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}" + }, + "spec" => { + "generation" => 2 + }, + "status" => { + "domain" => "#{name}.#{namespace}.#{domain}", + "domainInternal" => "#{name}.#{namespace}.svc.cluster.local", + "latestCreatedRevisionName" => "#{name}-00002", + "latestReadyRevisionName" => "#{name}-00002", + "observedGeneration" => 2 + } + } + end + + def kube_service_full(name: "kubetest", namespace: "kube-ns", domain: "example.com") + { + "metadata" => { + "creationTimestamp" => "2018-11-21T06:16:33Z", + "name" => name, + "namespace" => namespace, + "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}", + "annotation" => { + "description" => "This is a test description" + } + }, + "spec" => { + "generation" => 2, + "build" => { + "template" => "go-1.10.3" + } + }, + "status" => { + "domain" => "#{name}.#{namespace}.#{domain}", + "domainInternal" => "#{name}.#{namespace}.svc.cluster.local", + "latestCreatedRevisionName" => "#{name}-00002", + "latestReadyRevisionName" => "#{name}-00002", + "observedGeneration" => 2 + } + } + end + def kube_terminals(service, pod) pod_name = pod['metadata']['name'] containers = pod['spec']['containers'] diff --git a/spec/support/helpers/prometheus_helpers.rb b/spec/support/helpers/prometheus_helpers.rb index 4212be2cc88..ce1f9fce10d 100644 --- a/spec/support/helpers/prometheus_helpers.rb +++ b/spec/support/helpers/prometheus_helpers.rb @@ -49,11 +49,11 @@ module PrometheusHelpers "https://prometheus.example.com/api/v1/series?#{query}" end - def stub_prometheus_request(url, body: {}, status: 200) + def stub_prometheus_request(url, body: {}, status: 200, headers: {}) WebMock.stub_request(:get, url) .to_return({ status: status, - headers: { 'Content-Type' => 'application/json' }, + headers: { 'Content-Type' => 'application/json' }.merge(headers), body: body.to_json }) end diff --git a/spec/support/helpers/sorting_helper.rb b/spec/support/helpers/sorting_helper.rb index 9496a94d8f4..e505a6b7258 100644 --- a/spec/support/helpers/sorting_helper.rb +++ b/spec/support/helpers/sorting_helper.rb @@ -10,7 +10,7 @@ # module SortingHelper def sorting_by(value) - find('button.dropdown-toggle').click + find('.filter-dropdown-container button.dropdown-menu-toggle').click page.within('.content ul.dropdown-menu.dropdown-menu-right li') do click_link value end diff --git a/spec/support/helpers/stub_configuration.rb b/spec/support/helpers/stub_configuration.rb index 776119564ec..2851cd9733c 100644 --- a/spec/support/helpers/stub_configuration.rb +++ b/spec/support/helpers/stub_configuration.rb @@ -27,6 +27,11 @@ module StubConfiguration allow(Gitlab.config.gitlab).to receive_messages(to_settings(messages)) end + def stub_default_url_options(host: "localhost", protocol: "http") + url_options = { host: host, protocol: protocol } + allow(Rails.application.routes).to receive(:default_url_options).and_return(url_options) + end + def stub_gravatar_setting(messages) allow(Gitlab.config.gravatar).to receive_messages(to_settings(messages)) end diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 1f00cdf7e92..d52c40ff4f1 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -54,6 +54,9 @@ module TestEnv 'add_images_and_changes' => '010d106', 'update-gitlab-shell-v-6-0-1' => '2f61d70', 'update-gitlab-shell-v-6-0-3' => 'de78448', + 'merge-commit-analyze-before' => '1adbdef', + 'merge-commit-analyze-side-branch' => '8a99451', + 'merge-commit-analyze-after' => '646ece5', '2-mb-file' => 'bf12d25', 'before-create-delete-modify-move' => '845009f', 'between-create-delete-modify-move' => '3f5f443', diff --git a/spec/support/shared_contexts/change_access_checks_shared_context.rb b/spec/support/shared_contexts/change_access_checks_shared_context.rb new file mode 100644 index 00000000000..aca18b0c73b --- /dev/null +++ b/spec/support/shared_contexts/change_access_checks_shared_context.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +shared_context 'change access checks context' do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:user_access) { Gitlab::UserAccess.new(user, project: project) } + let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' } + let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + let(:ref) { 'refs/heads/master' } + let(:changes) { { oldrev: oldrev, newrev: newrev, ref: ref } } + let(:protocol) { 'ssh' } + let(:timeout) { Gitlab::GitAccess::INTERNAL_TIMEOUT } + let(:logger) { Gitlab::Checks::TimedLogger.new(timeout: timeout) } + let(:change_access) do + Gitlab::Checks::ChangeAccess.new( + changes, + project: project, + user_access: user_access, + protocol: protocol, + logger: logger + ) + end + + subject { described_class.new(change_access) } + + before do + project.add_developer(user) + end +end diff --git a/spec/support/shared_contexts/url_shared_context.rb b/spec/support/shared_contexts/url_shared_context.rb new file mode 100644 index 00000000000..1b1f67daac3 --- /dev/null +++ b/spec/support/shared_contexts/url_shared_context.rb @@ -0,0 +1,17 @@ +shared_context 'invalid urls' do + let(:urls_with_CRLF) do + ["http://127.0.0.1:333/pa\rth", + "http://127.0.0.1:333/pa\nth", + "http://127.0a.0.1:333/pa\r\nth", + "http://127.0.0.1:333/path?param=foo\r\nbar", + "http://127.0.0.1:333/path?param=foo\rbar", + "http://127.0.0.1:333/path?param=foo\nbar", + "http://127.0.0.1:333/pa%0dth", + "http://127.0.0.1:333/pa%0ath", + "http://127.0a.0.1:333/pa%0d%0th", + "http://127.0.0.1:333/pa%0D%0Ath", + "http://127.0.0.1:333/path?param=foo%0Abar", + "http://127.0.0.1:333/path?param=foo%0Dbar", + "http://127.0.0.1:333/path?param=foo%0D%0Abar"] + end +end diff --git a/spec/support/shared_examples/ci_trace_shared_examples.rb b/spec/support/shared_examples/ci_trace_shared_examples.rb index 377bd82b67e..c603421d748 100644 --- a/spec/support/shared_examples/ci_trace_shared_examples.rb +++ b/spec/support/shared_examples/ci_trace_shared_examples.rb @@ -180,10 +180,9 @@ shared_examples_for 'common trace features' do end context 'runners token' do - let(:token) { 'my_secret_token' } + let(:token) { build.project.runners_token } before do - build.project.update(runners_token: token) trace.set(token) end @@ -193,10 +192,9 @@ shared_examples_for 'common trace features' do end context 'hides build token' do - let(:token) { 'my_secret_token' } + let(:token) { build.token } before do - build.update(token: token) trace.set(token) end diff --git a/spec/support/shared_examples/diff_file_collections.rb b/spec/support/shared_examples/diff_file_collections.rb index 55ce160add0..367ddf06c28 100644 --- a/spec/support/shared_examples/diff_file_collections.rb +++ b/spec/support/shared_examples/diff_file_collections.rb @@ -45,3 +45,19 @@ shared_examples 'diff statistics' do |test_include_stats_flag: true| end end end + +shared_examples 'unfoldable diff' do + let(:subject) { described_class.new(diffable, diff_options: nil) } + + it 'calls Gitlab::Diff::File#unfold_diff_lines with correct position' do + position = instance_double(Gitlab::Diff::Position, file_path: 'README') + readme_file = instance_double(Gitlab::Diff::File, file_path: 'README') + other_file = instance_double(Gitlab::Diff::File, file_path: 'foo.rb') + nil_path_file = instance_double(Gitlab::Diff::File, file_path: nil) + + allow(subject).to receive(:diff_files) { [readme_file, other_file, nil_path_file] } + expect(readme_file).to receive(:unfold_diff_lines).with(position) + + subject.unfold_diff_files([position]) + end +end diff --git a/spec/support/shared_examples/file_finder.rb b/spec/support/shared_examples/file_finder.rb index ef144bdf61c..0dc351b5149 100644 --- a/spec/support/shared_examples/file_finder.rb +++ b/spec/support/shared_examples/file_finder.rb @@ -3,18 +3,19 @@ shared_examples 'file finder' do let(:search_results) { subject.find(query) } it 'finds by name' do - filename, blob = search_results.find { |_, blob| blob.filename == expected_file_by_name } - expect(filename).to eq(expected_file_by_name) - expect(blob).to be_a(Gitlab::SearchResults::FoundBlob) + blob = search_results.find { |blob| blob.filename == expected_file_by_name } + + expect(blob.filename).to eq(expected_file_by_name) + expect(blob).to be_a(Gitlab::Search::FoundBlob) expect(blob.ref).to eq(subject.ref) expect(blob.data).not_to be_empty end it 'finds by content' do - filename, blob = search_results.find { |_, blob| blob.filename == expected_file_by_content } + blob = search_results.find { |blob| blob.filename == expected_file_by_content } - expect(filename).to eq(expected_file_by_content) - expect(blob).to be_a(Gitlab::SearchResults::FoundBlob) + expect(blob.filename).to eq(expected_file_by_content) + expect(blob).to be_a(Gitlab::Search::FoundBlob) expect(blob.ref).to eq(subject.ref) expect(blob.data).not_to be_empty end diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb new file mode 100644 index 00000000000..77376496854 --- /dev/null +++ b/spec/support/shared_examples/models/member_shared_examples.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +shared_examples_for 'inherited access level as a member of entity' do + let(:parent_entity) { create(:group) } + let(:user) { create(:user) } + let(:member) { entity.is_a?(Group) ? entity.group_member(user) : entity.project_member(user) } + + context 'with root parent_entity developer member' do + before do + parent_entity.add_developer(user) + end + + it 'is allowed to be a maintainer of the entity' do + entity.add_maintainer(user) + + expect(member.access_level).to eq(Gitlab::Access::MAINTAINER) + end + + it 'is not allowed to be a reporter of the entity' do + entity.add_reporter(user) + + expect(member).to be_nil + end + + it 'is allowed to change to be a developer of the entity' do + entity.add_maintainer(user) + + expect { member.update(access_level: Gitlab::Access::DEVELOPER) } + .to change { member.access_level }.to(Gitlab::Access::DEVELOPER) + end + + it 'is not allowed to change to be a guest of the entity' do + entity.add_maintainer(user) + + expect { member.update(access_level: Gitlab::Access::GUEST) } + .not_to change { member.reload.access_level } + end + + it "shows an error if the member can't be updated" do + entity.add_maintainer(user) + + member.update(access_level: Gitlab::Access::REPORTER) + + expect(member.errors.full_messages).to eq(["Access level should be higher than Developer inherited membership from group #{parent_entity.name}"]) + end + + it 'allows changing the level from a non existing member' do + non_member_user = create(:user) + + entity.add_maintainer(non_member_user) + + non_member = entity.is_a?(Group) ? entity.group_member(non_member_user) : entity.project_member(non_member_user) + + expect { non_member.update(access_level: Gitlab::Access::GUEST) } + .to change { non_member.reload.access_level } + end + end +end + +shared_examples_for '#valid_level_roles' do |entity_name| + let(:member_user) { create(:user) } + let(:group) { create(:group) } + let(:entity) { create(entity_name) } + let(:entity_member) { create("#{entity_name}_member", :developer, source: entity, user: member_user) } + let(:presenter) { described_class.new(entity_member, current_user: member_user) } + let(:expected_roles) { { 'Developer' => 30, 'Maintainer' => 40, 'Reporter' => 20 } } + + it 'returns all roles when no parent member is present' do + expect(presenter.valid_level_roles).to eq(entity_member.class.access_level_roles) + end + + it 'returns higher roles when a parent member is present' do + group.add_reporter(member_user) + + expect(presenter.valid_level_roles).to eq(expected_roles) + end +end diff --git a/spec/support/shared_examples/models/with_uploads_shared_examples.rb b/spec/support/shared_examples/models/with_uploads_shared_examples.rb index 47ad0c6345d..1d11b855459 100644 --- a/spec/support/shared_examples/models/with_uploads_shared_examples.rb +++ b/spec/support/shared_examples/models/with_uploads_shared_examples.rb @@ -1,6 +1,6 @@ require 'spec_helper' -shared_examples_for 'model with mounted uploader' do |supports_fileuploads| +shared_examples_for 'model with uploads' do |supports_fileuploads| describe '.destroy' do before do stub_uploads_object_storage(uploader_class) @@ -8,16 +8,62 @@ shared_examples_for 'model with mounted uploader' do |supports_fileuploads| model_object.public_send(upload_attribute).migrate!(ObjectStorage::Store::REMOTE) end - it 'deletes remote uploads' do - expect_any_instance_of(CarrierWave::Storage::Fog::File).to receive(:delete).and_call_original + context 'with mounted uploader' do + it 'deletes remote uploads' do + expect_any_instance_of(CarrierWave::Storage::Fog::File).to receive(:delete).and_call_original - expect { model_object.destroy }.to change { Upload.count }.by(-1) + expect { model_object.destroy }.to change { Upload.count }.by(-1) + end end - it 'deletes any FileUploader uploads which are not mounted', skip: !supports_fileuploads do - create(:upload, uploader: FileUploader, model: model_object) + context 'with not mounted uploads', :sidekiq, skip: !supports_fileuploads do + context 'with local files' do + let!(:uploads) { create_list(:upload, 2, uploader: FileUploader, model: model_object) } - expect { model_object.destroy }.to change { Upload.count }.by(-2) + it 'deletes any FileUploader uploads which are not mounted' do + expect { model_object.destroy }.to change { Upload.count }.by(-3) + end + + it 'deletes local files' do + expect_any_instance_of(Uploads::Local).to receive(:delete_keys).with(uploads.map(&:absolute_path)) + + model_object.destroy + end + end + + context 'with remote files' do + let!(:uploads) { create_list(:upload, 2, :object_storage, uploader: FileUploader, model: model_object) } + + it 'deletes any FileUploader uploads which are not mounted' do + expect { model_object.destroy }.to change { Upload.count }.by(-3) + end + + it 'deletes remote files' do + expect_any_instance_of(Uploads::Fog).to receive(:delete_keys).with(uploads.map(&:path)) + + model_object.destroy + end + end + + describe 'destroy strategy depending on feature flag' do + let!(:upload) { create(:upload, uploader: FileUploader, model: model_object) } + + it 'does not destroy uploads by default' do + expect(model_object).to receive(:delete_uploads) + expect(model_object).not_to receive(:destroy_uploads) + + model_object.destroy + end + + it 'uses before destroy callback if feature flag is disabled' do + stub_feature_flags(fast_destroy_uploads: false) + + expect(model_object).to receive(:destroy_uploads) + expect(model_object).not_to receive(:delete_uploads) + + model_object.destroy + end + end end end end diff --git a/spec/support/shared_examples/only_except_policy_examples.rb b/spec/support/shared_examples/only_except_policy_examples.rb new file mode 100644 index 00000000000..35240af1d74 --- /dev/null +++ b/spec/support/shared_examples/only_except_policy_examples.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +shared_examples 'correct only except policy' do + context 'when using simplified policy' do + describe 'validations' do + context 'when entry config value is valid' do + context 'when config is a branch or tag name' do + let(:config) { %w[master feature/branch] } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + + describe '#value' do + it 'returns refs hash' do + expect(entry.value).to eq(refs: config) + end + end + end + + context 'when config is a regexp' do + let(:config) { ['/^issue-.*$/'] } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when config is a special keyword' do + let(:config) { %w[tags triggers branches] } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + end + + context 'when entry value is not valid' do + let(:config) { [1] } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include /policy config should be an array of strings or regexps/ + end + end + end + end + end + + context 'when using complex policy' do + context 'when specifying refs policy' do + let(:config) { { refs: ['master'] } } + + it 'is a correct configuraton' do + expect(entry).to be_valid + expect(entry.value).to eq(refs: %w[master]) + end + end + + context 'when specifying kubernetes policy' do + let(:config) { { kubernetes: 'active' } } + + it 'is a correct configuraton' do + expect(entry).to be_valid + expect(entry.value).to eq(kubernetes: 'active') + end + end + + context 'when specifying invalid kubernetes policy' do + let(:config) { { kubernetes: 'something' } } + + it 'reports an error about invalid policy' do + expect(entry.errors).to include /unknown value: something/ + 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 expression' do + expect(entry.errors).to include /invalid expression syntax/ + end + end + + context 'when using invalid variables expressions regexp' do + let(:config) { { variables: ['$MY_VAR =~ /some ( thing/'] } } + + it 'reports an error about invalid expression' do + expect(entry.errors).to include /invalid expression syntax/ + end + end + + context 'when specifying a valid changes policy' do + let(:config) { { changes: %w[some/* paths/**/*.rb] } } + + it 'is a correct configuraton' do + expect(entry).to be_valid + expect(entry.value).to eq(config) + end + end + + context 'when changes policy is invalid' do + let(:config) { { changes: [1, 2] } } + + it 'returns errors' do + expect(entry.errors).to include /changes should be an array of strings/ + end + end + + context 'when specifying unknown policy' do + let(:config) { { refs: ['master'], invalid: :something } } + + it 'returns error about invalid key' do + expect(entry.errors).to include /unknown keys: invalid/ + end + end + + context 'when policy is empty' do + let(:config) { {} } + + it 'is not a valid configuration' do + expect(entry.errors).to include /can't be blank/ + end + end + end + + context 'when policy strategy does not match' do + let(:config) { 'string strategy' } + + it 'returns information about errors' do + expect(entry.errors) + .to include /has to be either an array of conditions or a hash/ + end + end +end diff --git a/spec/support/shared_examples/serializers/diff_file_entity_examples.rb b/spec/support/shared_examples/serializers/diff_file_entity_examples.rb new file mode 100644 index 00000000000..b8065886c42 --- /dev/null +++ b/spec/support/shared_examples/serializers/diff_file_entity_examples.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +shared_examples 'diff file base entity' do + it 'exposes essential attributes' do + expect(subject).to include(:content_sha, :submodule, :submodule_link, + :submodule_tree_url, :old_path_html, + :new_path_html, :blob, :can_modify_blob, + :file_hash, :file_path, :old_path, :new_path, + :collapsed, :text, :diff_refs, :stored_externally, + :external_storage, :renamed_file, :deleted_file, + :mode_changed, :a_mode, :b_mode, :new_file) + end + + # Converted diff files from GitHub import does not contain blob file + # and content sha. + context 'when diff file does not have a blob and content sha' do + it 'exposes some attributes as nil' do + allow(diff_file).to receive(:content_sha).and_return(nil) + allow(diff_file).to receive(:blob).and_return(nil) + + expect(subject[:context_lines_path]).to be_nil + expect(subject[:view_path]).to be_nil + expect(subject[:highlighted_diff_lines]).to be_nil + expect(subject[:can_modify_blob]).to be_nil + end + end +end + +shared_examples 'diff file entity' do + it_behaves_like 'diff file base entity' + + it 'exposes correct attributes' do + expect(subject).to include(:too_large, :added_lines, :removed_lines, + :context_lines_path, :highlighted_diff_lines, + :parallel_diff_lines) + end + + it 'includes viewer' do + expect(subject[:viewer].with_indifferent_access) + .to match_schema('entities/diff_viewer') + end +end + +shared_examples 'diff file discussion entity' do + it_behaves_like 'diff file base entity' +end diff --git a/spec/tasks/gitlab/check_rake_spec.rb b/spec/tasks/gitlab/check_rake_spec.rb index 4eda618b6d6..06525e3c771 100644 --- a/spec/tasks/gitlab/check_rake_spec.rb +++ b/spec/tasks/gitlab/check_rake_spec.rb @@ -1,51 +1,101 @@ require 'rake_helper' -describe 'gitlab:ldap:check rake task' do - include LdapHelpers - +describe 'check.rake' do before do Rake.application.rake_require 'tasks/gitlab/check' stub_warn_user_is_not_gitlab end - context 'when LDAP is not enabled' do - it 'does not attempt to bind or search for users' do - expect(Gitlab::Auth::LDAP::Config).not_to receive(:providers) - expect(Gitlab::Auth::LDAP::Adapter).not_to receive(:open) - - run_rake_task('gitlab:ldap:check') + shared_examples_for 'system check rake task' do + it 'runs the check' do + expect do + subject + end.to output(/Checking #{name} ... Finished/).to_stdout end end - context 'when LDAP is enabled' do - let(:ldap) { double(:ldap) } - let(:adapter) { ldap_adapter('ldapmain', ldap) } + describe 'gitlab:check rake task' do + subject { run_rake_task('gitlab:check') } + let(:name) { 'GitLab subtasks' } - before do - allow(Gitlab::Auth::LDAP::Config) - .to receive_messages( - enabled?: true, - providers: ['ldapmain'] - ) - allow(Gitlab::Auth::LDAP::Adapter).to receive(:open).and_yield(adapter) - allow(adapter).to receive(:users).and_return([]) - end + it_behaves_like 'system check rake task' + end + + describe 'gitlab:gitlab_shell:check rake task' do + subject { run_rake_task('gitlab:gitlab_shell:check') } + let(:name) { 'GitLab Shell' } + + it_behaves_like 'system check rake task' + end + + describe 'gitlab:gitaly:check rake task' do + subject { run_rake_task('gitlab:gitaly:check') } + let(:name) { 'Gitaly' } + + it_behaves_like 'system check rake task' + end + + describe 'gitlab:sidekiq:check rake task' do + subject { run_rake_task('gitlab:sidekiq:check') } + let(:name) { 'Sidekiq' } - it 'attempts to bind using credentials' do - stub_ldap_config(has_auth?: true) + it_behaves_like 'system check rake task' + end - expect(ldap).to receive(:bind) + describe 'gitlab:incoming_email:check rake task' do + subject { run_rake_task('gitlab:incoming_email:check') } + let(:name) { 'Incoming Email' } - run_rake_task('gitlab:ldap:check') + it_behaves_like 'system check rake task' + end + + describe 'gitlab:ldap:check rake task' do + include LdapHelpers + + subject { run_rake_task('gitlab:ldap:check') } + let(:name) { 'LDAP' } + + it_behaves_like 'system check rake task' + + context 'when LDAP is not enabled' do + it 'does not attempt to bind or search for users' do + expect(Gitlab::Auth::LDAP::Config).not_to receive(:providers) + expect(Gitlab::Auth::LDAP::Adapter).not_to receive(:open) + + subject + end end - it 'searches for 100 LDAP users' do - stub_ldap_config(uid: 'uid') + context 'when LDAP is enabled' do + let(:ldap) { double(:ldap) } + let(:adapter) { ldap_adapter('ldapmain', ldap) } + + before do + allow(Gitlab::Auth::LDAP::Config) + .to receive_messages( + enabled?: true, + providers: ['ldapmain'] + ) + allow(Gitlab::Auth::LDAP::Adapter).to receive(:open).and_yield(adapter) + allow(adapter).to receive(:users).and_return([]) + end + + it 'attempts to bind using credentials' do + stub_ldap_config(has_auth?: true) + + expect(ldap).to receive(:bind) + + subject + end + + it 'searches for 100 LDAP users' do + stub_ldap_config(uid: 'uid') - expect(adapter).to receive(:users).with('uid', '*', 100) + expect(adapter).to receive(:users).with('uid', '*', 100) - run_rake_task('gitlab:ldap:check') + subject + end end end end diff --git a/spec/tasks/gitlab/web_hook_rake_spec.rb b/spec/tasks/gitlab/web_hook_rake_spec.rb new file mode 100644 index 00000000000..7bdf33ff6b0 --- /dev/null +++ b/spec/tasks/gitlab/web_hook_rake_spec.rb @@ -0,0 +1,92 @@ +require 'rake_helper' + +describe 'gitlab:web_hook namespace rake tasks' do + set(:group) { create(:group) } + + set(:project1) { create(:project, namespace: group) } + set(:project2) { create(:project, namespace: group) } + set(:other_group_project) { create(:project) } + + let(:url) { 'http://example.com' } + let(:hook_urls) { (project1.hooks + project2.hooks).map(&:url) } + let(:other_group_hook_urls) { other_group_project.hooks.map(&:url) } + + before do + Rake.application.rake_require 'tasks/gitlab/web_hook' + end + + describe 'gitlab:web_hook:add' do + it 'adds a web hook to all projects' do + stub_env('URL' => url) + run_rake_task('gitlab:web_hook:add') + + expect(hook_urls).to contain_exactly(url, url) + expect(other_group_hook_urls).to contain_exactly(url) + end + + it 'adds a web hook to projects in the specified namespace' do + stub_env('URL' => url, 'NAMESPACE' => group.full_path) + run_rake_task('gitlab:web_hook:add') + + expect(hook_urls).to contain_exactly(url, url) + expect(other_group_hook_urls).to be_empty + end + + it 'raises an error if an unknown namespace is specified' do + stub_env('URL' => url, 'NAMESPACE' => group.full_path) + + group.destroy + + expect { run_rake_task('gitlab:web_hook:add') }.to raise_error(SystemExit) + end + end + + describe 'gitlab:web_hook:rm' do + let!(:hook1) { create(:project_hook, project: project1, url: url) } + let!(:hook2) { create(:project_hook, project: project2, url: url) } + let!(:other_group_hook) { create(:project_hook, project: other_group_project, url: url) } + let!(:other_url_hook) { create(:project_hook, url: other_url, project: project1) } + + let(:other_url) { 'http://other.example.com' } + + it 'removes a web hook from all projects by URL' do + stub_env('URL' => url) + run_rake_task('gitlab:web_hook:rm') + + expect(hook_urls).to contain_exactly(other_url) + expect(other_group_hook_urls).to be_empty + end + + it 'removes a web hook from projects in the specified namespace by URL' do + stub_env('NAMESPACE' => group.full_path, 'URL' => url) + run_rake_task('gitlab:web_hook:rm') + + expect(hook_urls).to contain_exactly(other_url) + expect(other_group_hook_urls).to contain_exactly(url) + end + + it 'raises an error if an unknown namespace is specified' do + stub_env('URL' => url, 'NAMESPACE' => group.full_path) + + group.destroy + + expect { run_rake_task('gitlab:web_hook:rm') }.to raise_error(SystemExit) + end + end + + describe 'gitlab:web_hook:list' do + let!(:hook1) { create(:project_hook, project: project1) } + let!(:hook2) { create(:project_hook, project: project2) } + let!(:other_group_hook) { create(:project_hook, project: other_group_project) } + + it 'lists all web hooks' do + expect { run_rake_task('gitlab:web_hook:list') }.to output(/3 webhooks found/).to_stdout + end + + it 'lists web hooks in a particular namespace' do + stub_env('NAMESPACE', group.full_path) + + expect { run_rake_task('gitlab:web_hook:list') }.to output(/2 webhooks found/).to_stdout + end + end +end diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb index ab6100509a6..082d09d3f16 100644 --- a/spec/validators/url_validator_spec.rb +++ b/spec/validators/url_validator_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe UrlValidator do @@ -6,6 +8,30 @@ describe UrlValidator do include_examples 'url validator examples', described_class::DEFAULT_PROTOCOLS + describe 'validations' do + include_context 'invalid urls' + + let(:validator) { described_class.new(attributes: [:link_url]) } + + it 'returns error when url is nil' do + expect(validator.validate_each(badge, :link_url, nil)).to be_nil + expect(badge.errors.first[1]).to eq 'must be a valid URL' + end + + it 'returns error when url is empty' do + expect(validator.validate_each(badge, :link_url, '')).to be_nil + expect(badge.errors.first[1]).to eq 'must be a valid URL' + end + + it 'does not allow urls with CR or LF characters' do + aggregate_failures do + urls_with_CRLF.each do |url| + expect(validator.validate_each(badge, :link_url, url)[0]).to eq 'is blocked: URI is invalid' + end + end + end + end + context 'by default' do let(:validator) { described_class.new(attributes: [:link_url]) } diff --git a/spec/features/admin/admin_active_tab_spec.rb b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb index 1215908f5ea..05c2f61a606 100644 --- a/spec/features/admin/admin_active_tab_spec.rb +++ b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb @@ -1,27 +1,26 @@ require 'spec_helper' -RSpec.describe 'admin active tab' do - before do - sign_in(create(:admin)) - end - +describe 'layouts/nav/sidebar/_admin' do shared_examples 'page has active tab' do |title| it "activates #{title} tab" do - expect(page).to have_selector('.nav-sidebar .sidebar-top-level-items > li.active', count: 1) - expect(page.find('.nav-sidebar .sidebar-top-level-items > li.active')).to have_content(title) + render + + expect(rendered).to have_selector('.nav-sidebar .sidebar-top-level-items > li.active', count: 1) + expect(rendered).to have_css('.nav-sidebar .sidebar-top-level-items > li.active', text: title) end end shared_examples 'page has active sub tab' do |title| it "activates #{title} sub tab" do - expect(page).to have_selector('.sidebar-sub-level-items > li.active', count: 2) - expect(page.all('.sidebar-sub-level-items > li.active')[1]).to have_content(title) + render + + expect(rendered).to have_css('.sidebar-sub-level-items > li.active', text: title) end end context 'on home page' do before do - visit admin_root_path + allow(controller).to receive(:controller_name).and_return('dashboard') end it_behaves_like 'page has active tab', 'Overview' @@ -29,7 +28,8 @@ RSpec.describe 'admin active tab' do context 'on projects' do before do - visit admin_projects_path + allow(controller).to receive(:controller_name).and_return('projects') + allow(controller).to receive(:controller_path).and_return('admin/projects') end it_behaves_like 'page has active tab', 'Overview' @@ -38,7 +38,7 @@ RSpec.describe 'admin active tab' do context 'on groups' do before do - visit admin_groups_path + allow(controller).to receive(:controller_name).and_return('groups') end it_behaves_like 'page has active tab', 'Overview' @@ -47,7 +47,7 @@ RSpec.describe 'admin active tab' do context 'on users' do before do - visit admin_users_path + allow(controller).to receive(:controller_name).and_return('users') end it_behaves_like 'page has active tab', 'Overview' @@ -56,7 +56,7 @@ RSpec.describe 'admin active tab' do context 'on logs' do before do - visit admin_logs_path + allow(controller).to receive(:controller_name).and_return('logs') end it_behaves_like 'page has active tab', 'Monitoring' @@ -65,7 +65,7 @@ RSpec.describe 'admin active tab' do context 'on messages' do before do - visit admin_broadcast_messages_path + allow(controller).to receive(:controller_name).and_return('broadcast_messages') end it_behaves_like 'page has active tab', 'Messages' @@ -73,7 +73,7 @@ RSpec.describe 'admin active tab' do context 'on hooks' do before do - visit admin_hooks_path + allow(controller).to receive(:controller_name).and_return('hooks') end it_behaves_like 'page has active tab', 'Hooks' @@ -81,7 +81,7 @@ RSpec.describe 'admin active tab' do context 'on background jobs' do before do - visit admin_background_jobs_path + allow(controller).to receive(:controller_name).and_return('background_jobs') end it_behaves_like 'page has active tab', 'Monitoring' diff --git a/spec/views/projects/_home_panel.html.haml_spec.rb b/spec/views/projects/_home_panel.html.haml_spec.rb index fc1fe5739c3..006c93686d5 100644 --- a/spec/views/projects/_home_panel.html.haml_spec.rb +++ b/spec/views/projects/_home_panel.html.haml_spec.rb @@ -23,7 +23,7 @@ describe 'projects/_home_panel' do it 'makes it possible to set notification level' do render - expect(view).to render_template('shared/notifications/_button') + expect(view).to render_template('projects/buttons/_notifications') expect(rendered).to have_selector('.notification-dropdown') end end diff --git a/spec/workers/archive_trace_worker_spec.rb b/spec/workers/archive_trace_worker_spec.rb index b768588c6e1..7244ad4f199 100644 --- a/spec/workers/archive_trace_worker_spec.rb +++ b/spec/workers/archive_trace_worker_spec.rb @@ -5,10 +5,11 @@ describe ArchiveTraceWorker do subject { described_class.new.perform(job&.id) } context 'when job is found' do - let(:job) { create(:ci_build) } + let(:job) { create(:ci_build, :trace_live) } it 'executes service' do - expect_any_instance_of(Gitlab::Ci::Trace).to receive(:archive!) + expect_any_instance_of(Ci::ArchiveTraceService) + .to receive(:execute).with(job) subject end @@ -18,7 +19,8 @@ describe ArchiveTraceWorker do let(:job) { nil } it 'does not execute service' do - expect_any_instance_of(Gitlab::Ci::Trace).not_to receive(:archive!) + expect_any_instance_of(Ci::ArchiveTraceService) + .not_to receive(:execute) subject end diff --git a/spec/workers/ci/archive_traces_cron_worker_spec.rb b/spec/workers/ci/archive_traces_cron_worker_spec.rb index 23f5dda298a..478fb7d2c0f 100644 --- a/spec/workers/ci/archive_traces_cron_worker_spec.rb +++ b/spec/workers/ci/archive_traces_cron_worker_spec.rb @@ -30,6 +30,13 @@ describe Ci::ArchiveTracesCronWorker do it_behaves_like 'archives trace' + it 'executes service' do + expect_any_instance_of(Ci::ArchiveTraceService) + .to receive(:execute).with(build) + + subject + end + context 'when a trace had already been archived' do let!(:build) { create(:ci_build, :success, :trace_live, :trace_artifact) } let!(:build2) { create(:ci_build, :success, :trace_live) } @@ -46,11 +53,12 @@ describe Ci::ArchiveTracesCronWorker do let!(:build) { create(:ci_build, :success, :trace_live) } before do + allow(Gitlab::Sentry).to receive(:track_exception) allow_any_instance_of(Gitlab::Ci::Trace).to receive(:archive!).and_raise('Unexpected error') end it 'puts a log' do - expect(Rails.logger).to receive(:error).with("Failed to archive stale live trace. id: #{build.id} message: Unexpected error") + expect(Rails.logger).to receive(:error).with("Failed to archive trace. id: #{build.id} message: Unexpected error") subject end diff --git a/spec/workers/cluster_platform_configure_worker_spec.rb b/spec/workers/cluster_platform_configure_worker_spec.rb index b51f6e07c6a..0eead0ab13d 100644 --- a/spec/workers/cluster_platform_configure_worker_spec.rb +++ b/spec/workers/cluster_platform_configure_worker_spec.rb @@ -2,7 +2,43 @@ require 'spec_helper' -describe ClusterPlatformConfigureWorker, '#execute' do +describe ClusterPlatformConfigureWorker, '#perform' do + let(:worker) { described_class.new } + + context 'when group cluster' do + let(:cluster) { create(:cluster, :group, :provided_by_gcp) } + let(:group) { cluster.group } + + context 'when group has no projects' do + it 'does not create a namespace' do + expect_any_instance_of(Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService).not_to receive(:execute) + + worker.perform(cluster.id) + end + end + + context 'when group has a project' do + let!(:project) { create(:project, group: group) } + + it 'creates a namespace for the project' do + expect_any_instance_of(Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService).to receive(:execute).once + + worker.perform(cluster.id) + end + end + + context 'when group has project in a sub-group' do + let!(:subgroup) { create(:group, parent: group) } + let!(:project) { create(:project, group: subgroup) } + + it 'creates a namespace for the project' do + expect_any_instance_of(Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService).to receive(:execute).once + + worker.perform(cluster.id) + end + end + end + context 'when provider type is gcp' do let(:cluster) { create(:cluster, :project, :provided_by_gcp) } @@ -30,18 +66,4 @@ describe ClusterPlatformConfigureWorker, '#execute' do described_class.new.perform(123) end end - - context 'when kubeclient raises error' do - let(:cluster) { create(:cluster, :project) } - - it 'rescues and logs the error' do - allow_any_instance_of(Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService).to receive(:execute).and_raise(::Kubeclient::HttpError.new(500, 'something baaaad happened', '')) - - expect(Rails.logger) - .to receive(:error) - .with("Failed to create/update Kubernetes namespace for cluster_id: #{cluster.id} with error: something baaaad happened") - - described_class.new.perform(cluster.id) - end - end end diff --git a/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb b/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb index 241e8a2b6d3..d85a87f2cb0 100644 --- a/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb +++ b/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb @@ -58,14 +58,16 @@ describe Gitlab::GithubImport::StageMethods do end describe '#find_project' do + let(:import_state) { create(:import_state, project: project) } + it 'returns a Project for an existing ID' do - project.update_column(:import_status, 'started') + import_state.update_column(:status, 'started') expect(worker.find_project(project.id)).to eq(project) end it 'returns nil for a project that failed importing' do - project.update_column(:import_status, 'failed') + import_state.update_column(:status, 'failed') expect(worker.find_project(project.id)).to be_nil end diff --git a/spec/workers/concerns/project_import_options_spec.rb b/spec/workers/concerns/project_import_options_spec.rb index b6c111df8b9..3699fd83a9a 100644 --- a/spec/workers/concerns/project_import_options_spec.rb +++ b/spec/workers/concerns/project_import_options_spec.rb @@ -28,13 +28,23 @@ describe ProjectImportOptions do worker_class.sidekiq_retries_exhausted_block.call(job) - expect(project.reload.import_error).to include("fork") + expect(project.import_state.reload.last_error).to include("fork") end it 'logs the appropriate error message for forked projects' do worker_class.sidekiq_retries_exhausted_block.call(job) - expect(project.reload.import_error).to include("import") + expect(project.import_state.reload.last_error).to include("import") + end + + context 'when project does not have import_state' do + let(:project) { create(:project) } + + it 'raises an error' do + expect do + worker_class.sidekiq_retries_exhausted_block.call(job) + end.to raise_error(NoMethodError) + end end end end diff --git a/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb b/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb index 0f78c5cc644..fc7aafbc0c9 100644 --- a/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb +++ b/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb @@ -17,8 +17,8 @@ describe Gitlab::GithubImport::AdvanceStageWorker, :clean_gitlab_redis_shared_st context 'when there are remaining jobs' do before do allow(worker) - .to receive(:find_project) - .and_return(project) + .to receive(:find_import_state) + .and_return(import_state) end it 'reschedules itself' do @@ -38,8 +38,8 @@ describe Gitlab::GithubImport::AdvanceStageWorker, :clean_gitlab_redis_shared_st context 'when there are no remaining jobs' do before do allow(worker) - .to receive(:find_project) - .and_return(project) + .to receive(:find_import_state) + .and_return(import_state) allow(worker) .to receive(:wait_for_jobs) @@ -48,8 +48,8 @@ describe Gitlab::GithubImport::AdvanceStageWorker, :clean_gitlab_redis_shared_st end it 'schedules the next stage' do - expect(project) - .to receive(:refresh_import_jid_expiration) + expect(import_state) + .to receive(:refresh_jid_expiration) expect(Gitlab::GithubImport::Stage::FinishImportWorker) .to receive(:perform_async) @@ -96,22 +96,18 @@ describe Gitlab::GithubImport::AdvanceStageWorker, :clean_gitlab_redis_shared_st end end - describe '#find_project' do - it 'returns a Project' do - project.update_column(:import_status, 'started') + describe '#find_import_state' do + it 'returns a ProjectImportState' do + import_state.update_column(:status, 'started') - found = worker.find_project(project.id) + found = worker.find_import_state(project.id) - expect(found).to be_an_instance_of(Project) - - # This test is there to make sure we only select the columns we care - # about. - # TODO: enable this assertion back again - # expect(found.attributes).to include({ 'id' => nil, 'import_jid' => '123' }) + expect(found).to be_an_instance_of(ProjectImportState) + expect(found.attributes.keys).to match_array(%w(id jid)) end it 'returns nil if the project import is not running' do - expect(worker.find_project(project.id)).to be_nil + expect(worker.find_import_state(project.id)).to be_nil end end end diff --git a/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb b/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb index 25ada575a44..7ff133f1049 100644 --- a/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb +++ b/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb @@ -29,7 +29,7 @@ describe Gitlab::GithubImport::RefreshImportJidWorker do context 'when the job is running' do it 'refreshes the import JID and reschedules itself' do allow(worker) - .to receive(:find_project) + .to receive(:find_import_state) .with(project.id) .and_return(project) @@ -39,7 +39,7 @@ describe Gitlab::GithubImport::RefreshImportJidWorker do .and_return(true) expect(project) - .to receive(:refresh_import_jid_expiration) + .to receive(:refresh_jid_expiration) expect(worker.class) .to receive(:perform_in_the_future) @@ -52,7 +52,7 @@ describe Gitlab::GithubImport::RefreshImportJidWorker do context 'when the job is no longer running' do it 'returns' do allow(worker) - .to receive(:find_project) + .to receive(:find_import_state) .with(project.id) .and_return(project) @@ -62,18 +62,18 @@ describe Gitlab::GithubImport::RefreshImportJidWorker do .and_return(false) expect(project) - .not_to receive(:refresh_import_jid_expiration) + .not_to receive(:refresh_jid_expiration) worker.perform(project.id, '123') end end end - describe '#find_project' do - it 'returns a Project' do + describe '#find_import_state' do + it 'returns a ProjectImportState' do project = create(:project, :import_started) - expect(worker.find_project(project.id)).to be_an_instance_of(Project) + expect(worker.find_import_state(project.id)).to be_an_instance_of(ProjectImportState) end # it 'only selects the import JID field' do @@ -84,14 +84,14 @@ describe Gitlab::GithubImport::RefreshImportJidWorker do # .to eq({ 'id' => nil, 'import_jid' => '123abc' }) # end - it 'returns nil for a project for which the import process failed' do + it 'returns nil for a import state for which the import process failed' do project = create(:project, :import_failed) - expect(worker.find_project(project.id)).to be_nil + expect(worker.find_import_state(project.id)).to be_nil end - it 'returns nil for a non-existing project' do - expect(worker.find_project(-1)).to be_nil + it 'returns nil for a non-existing find_import_state' do + expect(worker.find_import_state(-1)).to be_nil end end end diff --git a/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb index 8c80d660287..ad6154cc4a4 100644 --- a/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb +++ b/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::GithubImport::Stage::ImportBaseDataWorker do let(:project) { create(:project) } + let(:import_state) { create(:import_state, project: project) } let(:worker) { described_class.new } describe '#import' do @@ -18,7 +19,7 @@ describe Gitlab::GithubImport::Stage::ImportBaseDataWorker do expect(importer).to receive(:execute) end - expect(project).to receive(:refresh_import_jid_expiration) + expect(import_state).to receive(:refresh_jid_expiration) expect(Gitlab::GithubImport::Stage::ImportPullRequestsWorker) .to receive(:perform_async) diff --git a/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb index 2fc91a3e80a..1fbb073a34a 100644 --- a/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb +++ b/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::GithubImport::Stage::ImportPullRequestsWorker do let(:project) { create(:project) } + let(:import_state) { create(:import_state, project: project) } let(:worker) { described_class.new } describe '#import' do @@ -19,8 +20,8 @@ describe Gitlab::GithubImport::Stage::ImportPullRequestsWorker do .to receive(:execute) .and_return(waiter) - expect(project) - .to receive(:refresh_import_jid_expiration) + expect(import_state) + .to receive(:refresh_jid_expiration) expect(Gitlab::GithubImport::AdvanceStageWorker) .to receive(:perform_async) diff --git a/spec/workers/pipeline_schedule_worker_spec.rb b/spec/workers/pipeline_schedule_worker_spec.rb index a2fe4734d47..ff408427926 100644 --- a/spec/workers/pipeline_schedule_worker_spec.rb +++ b/spec/workers/pipeline_schedule_worker_spec.rb @@ -11,6 +11,7 @@ describe PipelineScheduleWorker do end before do + stub_application_setting(auto_devops_enabled: false) stub_ci_pipeline_to_return_yaml_file pipeline_schedule.update_column(:next_run_at, 1.day.ago) @@ -24,12 +25,12 @@ describe PipelineScheduleWorker do context 'when there is a scheduled pipeline within next_run_at' do shared_examples 'successful scheduling' do it 'creates a new pipeline' do - expect { subject }.to change { project.pipelines.count }.by(1) + expect { subject }.to change { project.ci_pipelines.count }.by(1) expect(Ci::Pipeline.last).to be_schedule pipeline_schedule.reload expect(pipeline_schedule.next_run_at).to be > Time.now - expect(pipeline_schedule).to eq(project.pipelines.last.pipeline_schedule) + expect(pipeline_schedule).to eq(project.ci_pipelines.last.pipeline_schedule) expect(pipeline_schedule).to be_active end end @@ -53,20 +54,92 @@ describe PipelineScheduleWorker do end it 'does not creates a new pipeline' do - expect { subject }.not_to change { project.pipelines.count } + expect { subject }.not_to change { project.ci_pipelines.count } + end + end + + context 'when gitlab-ci.yml is corrupted' do + before do + stub_ci_pipeline_yaml_file(YAML.dump(rspec: { variables: 'rspec' } )) + end + + it 'creates a failed pipeline with the reason' do + expect { subject }.to change { project.ci_pipelines.count }.by(1) + expect(Ci::Pipeline.last).to be_config_error + expect(Ci::Pipeline.last.yaml_errors).not_to be_nil end end end context 'when the schedule is not runnable by the user' do - it 'deactivates the schedule' do + before do + expect(Gitlab::Sentry) + .to receive(:track_exception) + .with(Ci::CreatePipelineService::CreateError, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', + extra: { schedule_id: pipeline_schedule.id } ).once + end + + it 'does not deactivate the schedule' do subject - expect(pipeline_schedule.reload.active).to be_falsy + expect(pipeline_schedule.reload.active).to be_truthy + end + + it 'increments Prometheus counter' do + expect(Gitlab::Metrics) + .to receive(:counter) + .with(:pipeline_schedule_creation_failed_total, "Counter of failed attempts of pipeline schedule creation") + .and_call_original + + subject + end + + it 'logging a pipeline error' do + expect(Rails.logger) + .to receive(:error) + .with(a_string_matching("Insufficient permissions to create a new pipeline")) + .and_call_original + + subject + end + + it 'does not create a pipeline' do + expect { subject }.not_to change { project.ci_pipelines.count } + end + + it 'does not raise an exception' do + expect { subject }.not_to raise_error + end + end + + context 'when .gitlab-ci.yml is missing in the project' do + before do + stub_ci_pipeline_yaml_file(nil) + project.add_maintainer(user) + + expect(Gitlab::Sentry) + .to receive(:track_exception) + .with(Ci::CreatePipelineService::CreateError, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', + extra: { schedule_id: pipeline_schedule.id } ).once + end + + it 'logging a pipeline error' do + expect(Rails.logger) + .to receive(:error) + .with(a_string_matching("Missing .gitlab-ci.yml file")) + .and_call_original + + subject + end + + it 'does not create a pipeline' do + expect { subject }.not_to change { project.ci_pipelines.count } end - it 'does not schedule a pipeline' do - expect { subject }.not_to change { project.pipelines.count } + it 'does not raise an exception' do + expect { subject }.not_to raise_error end end end diff --git a/spec/workers/prune_web_hook_logs_worker_spec.rb b/spec/workers/prune_web_hook_logs_worker_spec.rb index d7d64a1f641..b3ec71d4a00 100644 --- a/spec/workers/prune_web_hook_logs_worker_spec.rb +++ b/spec/workers/prune_web_hook_logs_worker_spec.rb @@ -5,18 +5,20 @@ describe PruneWebHookLogsWorker do before do hook = create(:project_hook) - 5.times do - create(:web_hook_log, web_hook: hook, created_at: 5.months.ago) - end - + create(:web_hook_log, web_hook: hook, created_at: 5.months.ago) + create(:web_hook_log, web_hook: hook, created_at: 4.months.ago) + create(:web_hook_log, web_hook: hook, created_at: 91.days.ago) + create(:web_hook_log, web_hook: hook, created_at: 89.days.ago) + create(:web_hook_log, web_hook: hook, created_at: 2.months.ago) + create(:web_hook_log, web_hook: hook, created_at: 1.month.ago) create(:web_hook_log, web_hook: hook, response_status: '404') end - it 'removes all web hook logs older than one month' do + it 'removes all web hook logs older than 90 days' do described_class.new.perform - expect(WebHookLog.count).to eq(1) - expect(WebHookLog.first.response_status).to eq('404') + expect(WebHookLog.count).to eq(4) + expect(WebHookLog.last.response_status).to eq('404') end end end diff --git a/spec/workers/rebase_worker_spec.rb b/spec/workers/rebase_worker_spec.rb index 936b9deaecc..900332ed6b3 100644 --- a/spec/workers/rebase_worker_spec.rb +++ b/spec/workers/rebase_worker_spec.rb @@ -19,7 +19,7 @@ describe RebaseWorker, '#perform' do expect(MergeRequests::RebaseService) .to receive(:new).with(forked_project, merge_request.author).and_call_original - subject.perform(merge_request, merge_request.author) + subject.perform(merge_request.id, merge_request.author.id) end end end diff --git a/spec/workers/remove_old_web_hook_logs_worker_spec.rb b/spec/workers/remove_old_web_hook_logs_worker_spec.rb deleted file mode 100644 index 6d26ba5dfa0..00000000000 --- a/spec/workers/remove_old_web_hook_logs_worker_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -require 'spec_helper' - -describe RemoveOldWebHookLogsWorker do - subject { described_class.new } - - describe '#perform' do - let!(:week_old_record) { create(:web_hook_log, created_at: Time.now - 1.week) } - let!(:three_days_old_record) { create(:web_hook_log, created_at: Time.now - 3.days) } - let!(:one_day_old_record) { create(:web_hook_log, created_at: Time.now - 1.day) } - - it 'removes web hook logs older than 2 days' do - subject.perform - - expect(WebHookLog.all).to include(one_day_old_record) - expect(WebHookLog.all).not_to include(week_old_record, three_days_old_record) - end - end -end diff --git a/spec/workers/repository_cleanup_worker_spec.rb b/spec/workers/repository_cleanup_worker_spec.rb new file mode 100644 index 00000000000..3adae0b6cfa --- /dev/null +++ b/spec/workers/repository_cleanup_worker_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe RepositoryCleanupWorker do + let(:project) { create(:project) } + let(:user) { create(:user) } + + subject(:worker) { described_class.new } + + describe '#perform' do + it 'executes the cleanup service and sends a success notification' do + expect_next_instance_of(Projects::CleanupService) do |service| + expect(service.project).to eq(project) + expect(service.current_user).to eq(user) + + expect(service).to receive(:execute) + end + + expect_next_instance_of(NotificationService) do |service| + expect(service).to receive(:repository_cleanup_success).with(project, user) + end + + worker.perform(project.id, user.id) + end + + it 'raises an error if the project cannot be found' do + project.destroy + + expect { worker.perform(project.id, user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises an error if the user cannot be found' do + user.destroy + + expect { worker.perform(project.id, user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe '#sidekiq_retries_exhausted' do + let(:job) { { 'args' => [project.id, user.id], 'error_message' => 'Error' } } + + it 'does not send a failure notification for a RecordNotFound error' do + expect(NotificationService).not_to receive(:new) + + described_class.sidekiq_retries_exhausted_block.call(job, ActiveRecord::RecordNotFound.new) + end + + it 'sends a failure notification' do + expect_next_instance_of(NotificationService) do |service| + expect(service).to receive(:repository_cleanup_failure).with(project, user, 'Error') + end + + described_class.sidekiq_retries_exhausted_block.call(job, StandardError.new) + end + end +end diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb index d07e40377d4..87ac4bc05c1 100644 --- a/spec/workers/repository_import_worker_spec.rb +++ b/spec/workers/repository_import_worker_spec.rb @@ -9,13 +9,13 @@ describe RepositoryImportWorker do describe '#perform' do let(:project) { create(:project, :import_scheduled) } + let(:import_state) { project.import_state } context 'when worker was reset without cleanup' do it 'imports the project successfully' do jid = '12345678' started_project = create(:project) - - create(:import_state, :started, project: started_project, jid: jid) + started_import_state = create(:import_state, :started, project: started_project, jid: jid) allow(subject).to receive(:jid).and_return(jid) @@ -23,12 +23,12 @@ describe RepositoryImportWorker do .and_return({ status: :ok }) # Works around https://github.com/rspec/rspec-mocks/issues/910 - expect(Project).to receive(:find).with(project.id).and_return(project) - expect(project.repository).to receive(:expire_emptiness_caches) - expect(project.wiki.repository).to receive(:expire_emptiness_caches) - expect(project).to receive(:import_finish) + expect(Project).to receive(:find).with(started_project.id).and_return(started_project) + expect(started_project.repository).to receive(:expire_emptiness_caches) + expect(started_project.wiki.repository).to receive(:expire_emptiness_caches) + expect(started_import_state).to receive(:finish) - subject.perform(project.id) + subject.perform(started_project.id) end end @@ -41,7 +41,7 @@ describe RepositoryImportWorker do expect(Project).to receive(:find).with(project.id).and_return(project) expect(project.repository).to receive(:expire_emptiness_caches) expect(project.wiki.repository).to receive(:expire_emptiness_caches) - expect(project).to receive(:import_finish) + expect(import_state).to receive(:finish) subject.perform(project.id) end @@ -51,26 +51,27 @@ describe RepositoryImportWorker do it 'hide the credentials that were used in the import URL' do error = %q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found } - project.update(import_jid: '123') + import_state.update(jid: '123') expect_any_instance_of(Projects::ImportService).to receive(:execute).and_return({ status: :error, message: error }) expect do subject.perform(project.id) end.to raise_error(RuntimeError, error) - expect(project.reload.import_jid).not_to be_nil + expect(import_state.reload.jid).not_to be_nil end it 'updates the error on Import/Export' do error = %q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found } - project.update(import_jid: '123', import_type: 'gitlab_project') + project.update(import_type: 'gitlab_project') + import_state.update(jid: '123') expect_any_instance_of(Projects::ImportService).to receive(:execute).and_return({ status: :error, message: error }) expect do subject.perform(project.id) end.to raise_error(RuntimeError, error) - expect(project.reload.import_error).not_to be_nil + expect(import_state.reload.last_error).not_to be_nil end end @@ -90,8 +91,8 @@ describe RepositoryImportWorker do .to receive(:async?) .and_return(true) - expect_any_instance_of(Project) - .not_to receive(:import_finish) + expect_any_instance_of(ProjectImportState) + .not_to receive(:finish) subject.perform(project.id) end diff --git a/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb b/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb index 9adde5fc21a..a2bc264b0f6 100644 --- a/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb +++ b/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb @@ -34,5 +34,33 @@ describe UpdateHeadPipelineForMergeRequestWorker do expect { subject.perform(merge_request.id) }.not_to change { merge_request.reload.head_pipeline_id } end end + + context 'when a merge request pipeline exists' do + let!(:merge_request_pipeline) do + create(:ci_pipeline, + project: project, + source: :merge_request, + sha: latest_sha, + merge_request: merge_request) + end + + it 'sets the merge request pipeline as the head pipeline' do + expect { subject.perform(merge_request.id) } + .to change { merge_request.reload.head_pipeline_id } + .from(nil).to(merge_request_pipeline.id) + end + + context 'when branch pipeline exists' do + let!(:branch_pipeline) do + create(:ci_pipeline, project: project, source: :push, sha: latest_sha) + end + + it 'prioritizes the merge request pipeline as the head pipeline' do + expect { subject.perform(merge_request.id) } + .to change { merge_request.reload.head_pipeline_id } + .from(nil).to(merge_request_pipeline.id) + end + end + end end end diff --git a/vendor/jupyter/values.yaml b/vendor/jupyter/values.yaml index 24136a7aca5..781d6e3042f 100644 --- a/vendor/jupyter/values.yaml +++ b/vendor/jupyter/values.yaml @@ -22,3 +22,4 @@ ingress: enabled: true annotations: kubernetes.io/ingress.class: "nginx" + kubernetes.io/tls-acme: "true" diff --git a/yarn.lock b/yarn.lock index f47045ad6f6..d7d2b89a881 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@babel/code-frame@^7.0.0": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.0.0-beta.35": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA== @@ -629,19 +629,20 @@ eslint-plugin-promise "^4.0.1" eslint-plugin-vue "^5.0.0-beta.3" -"@gitlab/svgs@^1.38.0": - version "1.38.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.38.0.tgz#e2f6e73379d60c7c63af4df8242a94c4671a1dfe" - integrity sha512-Mzv6PxVbWEPvvMgXHaGxk8UE1Gard2gifca6loLgfLH7BtjXfESiZyJdQkkTSeBYp5MoqQa88Kw+vJYobwjsSw== +"@gitlab/svgs@^1.40.0": + version "1.41.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.41.0.tgz#f80e3a0e259f3550af00685556ea925e471276d3" + integrity sha512-tKUXyqe54efWBsjQBUcvNF0AvqmE2NI2No3Bnix/gKDRImzIlcgIkM67Y8zoJv1D0w4CO87WcaG5GLpIFIT1Pg== -"@gitlab/ui@^1.11.0": - version "1.11.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.11.0.tgz#b771c2c3d627cf9efbe98c71ee5739624f2ff51f" - integrity sha512-hGMHM45kcv9725R6G+n/HxvF3KfVb9oBGRNf1+4n3xAGmtXJ2NlPdIXIsDaye3EeVF9PTOtjLuaqrcp6AGNqZg== +"@gitlab/ui@^1.14.0": + version "1.14.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.14.0.tgz#f0fd7c0e6c45a36ab3be18d00e2908a8cb405f90" + integrity sha512-jkBTN8qO41A894kcLo6b/mfLIgL8YNn+ZzjgzEXaZ3PyeQ3mKBdrBoSYkzH556qviroBvk/+3yyZz96VUo08qQ== dependencies: babel-standalone "^6.26.0" bootstrap-vue "^2.0.0-rc.11" copy-to-clipboard "^3.0.8" + echarts "^4.2.0-rc.2" highlight.js "^9.13.1" js-beautify "^1.8.8" lodash "^4.17.11" @@ -882,6 +883,11 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.1.tgz#5c85d662f76fa1d34575766c5dcd6615abcd30d8" integrity sha512-FZdkNBDqBRHKQ2MEbSC17xnPFOhZxeJ2YGSfr2BKf3sujG49Qe3bB+rGCwQfIaA7WHnGeGkSijX4FuBCdrzW/g== +abab@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f" + integrity sha512-sY5AXXVZv4Y1VACTtR11UJCPHHudgY5i26Qj5TypE6DKlIApbwb5uqhXcJ5UUGbvZNRh7EeIoW+LrJumBsKp7w== + abbrev@1, abbrev@1.0.x: version "1.0.9" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135" @@ -902,6 +908,14 @@ acorn-dynamic-import@^3.0.0: dependencies: acorn "^5.0.0" +acorn-globals@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.3.0.tgz#e3b6f8da3c1552a95ae627571f7dd6923bb54103" + integrity sha512-hMtHj3s5RnuhvHPowpBYvJVj3rAar82JiDQHvGs1zO0l10ocX/xEdBShNHTJaboucJUsScghp74pH3s7EnHHQw== + dependencies: + acorn "^6.0.1" + acorn-walk "^6.0.1" + acorn-jsx@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-4.1.1.tgz#e8e41e48ea2fe0c896740610ab6a4ffd8add225e" @@ -909,11 +923,21 @@ acorn-jsx@^4.1.1: dependencies: acorn "^5.0.3" -acorn@^5.0.0, acorn@^5.0.3, acorn@^5.6.0, acorn@^5.6.2, acorn@^5.7.3: +acorn-walk@^6.0.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-6.1.1.tgz#d363b66f5fac5f018ff9c3a1e7b6f8e310cc3913" + integrity sha512-OtUw6JUTgxA2QoqqmrmQ7F2NYqiBPi/L2jqHyFtllhOUvXYQXf0Z1CYUinIfyT4bTCGmrA7gX9FvHA81uzCoVw== + +acorn@^5.0.0, acorn@^5.0.3, acorn@^5.5.3, acorn@^5.6.0, acorn@^5.6.2, acorn@^5.7.3: version "5.7.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw== +acorn@^6.0.1: + version "6.0.4" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.0.4.tgz#77377e7353b72ec5104550aa2d2097a2fd40b754" + integrity sha512-VY4i5EKSKkofY2I+6QLTbTTN/UvEQPCo6eiwzzSaSWfpaDhOmStMCMod6wmuPciNq+XS0faCglFu2lHZpdHUtg== + after@0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" @@ -939,6 +963,16 @@ ajv@^6.0.1, ajv@^6.1.0, ajv@^6.5.3: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^6.5.5: + version "6.5.5" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.5.tgz#cf97cdade71c6399a92c6d6c4177381291b781a1" + integrity sha512-7q7gtRQDJSyuEHjuVgHoUa2VuemFiCMrfQc9Tc08XTAc4Zj/5U1buQJ0HU6i7fKjXU09SVgSmxa4sLvuvS8Iyg== + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + amdefine@>=0.0.4: version "1.0.1" resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" @@ -986,7 +1020,7 @@ ansi-styles@^2.2.1: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= -ansi-styles@^3.2.1: +ansi-styles@^3.2.0, ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== @@ -1098,6 +1132,13 @@ apollo-utilities@1.0.25, apollo-utilities@^1.0.0, apollo-utilities@^1.0.25, apol dependencies: fast-json-stable-stringify "^2.0.0" +append-transform@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-0.4.0.tgz#d76ebf8ca94d276e247a36bad44a4b74ab611991" + integrity sha1-126/jKlNJ24keja61EpLdKthGZE= + dependencies: + default-require-extensions "^1.0.0" + append-transform@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-1.0.0.tgz#046a52ae582a228bd72f58acfbe2967c678759ab" @@ -1125,12 +1166,19 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +arr-diff@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" + integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8= + dependencies: + arr-flatten "^1.0.1" + arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= -arr-flatten@^1.1.0: +arr-flatten@^1.0.1, arr-flatten@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== @@ -1140,6 +1188,11 @@ arr-union@^3.1.0: resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= +array-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" + integrity sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM= + array-find@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/array-find/-/array-find-1.0.0.tgz#6c8e286d11ed768327f8e62ecee87353ca3e78b8" @@ -1201,6 +1254,18 @@ asn1.js@^4.0.0: inherits "^2.0.1" minimalistic-assert "^1.0.0" +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + assert@^1.1.1: version "1.4.1" resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" @@ -1213,6 +1278,11 @@ assign-symbols@^1.0.0: resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= +astral-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" + integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== + async-each@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" @@ -1228,13 +1298,18 @@ async@1.x, async@^1.5.2: resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= -async@^2.0.0, async@^2.5.0, async@^2.6.1: +async@^2.0.0, async@^2.1.4, async@^2.5.0, async@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" integrity sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ== dependencies: lodash "^4.17.10" +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + atob@^2.0.0: version "2.1.2" resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" @@ -1245,6 +1320,16 @@ autosize@^4.0.0: resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.0.tgz#7a0599b1ba84d73bd7589b0d9da3870152c69237" integrity sha1-egWZsbqE1zvXWJsNnaOHAVLGkjc= +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= + +aws4@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" + integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== + axios-mock-adapter@^1.15.0: version "1.15.0" resolved "https://registry.yarnpkg.com/axios-mock-adapter/-/axios-mock-adapter-1.15.0.tgz#fbc06825d8302c95c3334d21023bba996255d45d" @@ -1269,6 +1354,36 @@ babel-code-frame@^6.26.0: esutils "^2.0.2" js-tokens "^3.0.2" +babel-core@^6.0.0, babel-core@^6.26.0: + version "6.26.3" + resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207" + integrity sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA== + dependencies: + babel-code-frame "^6.26.0" + babel-generator "^6.26.0" + babel-helpers "^6.24.1" + babel-messages "^6.23.0" + babel-register "^6.26.0" + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + convert-source-map "^1.5.1" + debug "^2.6.9" + json5 "^0.5.1" + lodash "^4.17.4" + minimatch "^3.0.4" + path-is-absolute "^1.0.1" + private "^0.1.8" + slash "^1.0.0" + source-map "^0.5.7" + +babel-core@^7.0.0-bridge: + version "7.0.0-bridge.0" + resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece" + integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg== + babel-eslint@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.1.tgz#919681dc099614cd7d31d45c8908695092a1faed" @@ -1281,6 +1396,36 @@ babel-eslint@^10.0.1: eslint-scope "3.7.1" eslint-visitor-keys "^1.0.0" +babel-generator@^6.18.0, babel-generator@^6.26.0: + version "6.26.1" + resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90" + integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA== + dependencies: + babel-messages "^6.23.0" + babel-runtime "^6.26.0" + babel-types "^6.26.0" + detect-indent "^4.0.0" + jsesc "^1.3.0" + lodash "^4.17.4" + source-map "^0.5.7" + trim-right "^1.0.1" + +babel-helpers@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2" + integrity sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI= + dependencies: + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-jest@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-23.6.0.tgz#a644232366557a2240a0c083da6b25786185a2f1" + integrity sha512-lqKGG6LYXYu+DQh/slrQ8nxXQkEkhugdXsU6St7GmhVS7Ilc/22ArwqXNJrf0QaOBjZB0360qZMwXqDYQHXaew== + dependencies: + babel-plugin-istanbul "^4.1.6" + babel-preset-jest "^23.2.0" + babel-loader@^8.0.4: version "8.0.4" resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.0.4.tgz#7bbf20cbe4560629e2e41534147692d3fecbdce6" @@ -1298,6 +1443,23 @@ babel-messages@^6.23.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-dynamic-import-node@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.2.0.tgz#c0adfb07d95f4a4495e9aaac6ec386c4d7c2524e" + integrity sha512-fP899ELUnTaBcIzmrW7nniyqqdYWrWuJUyPWHxFa/c7r7hS6KC8FscNfLlBNIoPSc55kYMGEEKjPjJGCLbE1qA== + dependencies: + object.assign "^4.1.0" + +babel-plugin-istanbul@^4.1.6: + version "4.1.6" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" + integrity sha512-PWP9FQ1AhZhS01T/4qLSKoHGY/xvkZdVBGlKM/HuxxS3+sC66HhTNR7+MpbO/so/cz/wY94MeSWJuP1hXIPfwQ== + dependencies: + babel-plugin-syntax-object-rest-spread "^6.13.0" + find-up "^2.1.0" + istanbul-lib-instrument "^1.10.1" + test-exclude "^4.2.1" + babel-plugin-istanbul@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-5.1.0.tgz#6892f529eff65a3e2d33d87dc5888ffa2ecd4a30" @@ -1307,11 +1469,39 @@ babel-plugin-istanbul@^5.1.0: istanbul-lib-instrument "^3.0.0" test-exclude "^5.0.0" +babel-plugin-jest-hoist@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" + integrity sha1-5h+uBaHKiAGq3uV6bWa4zvr0QWc= + babel-plugin-rewire@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/babel-plugin-rewire/-/babel-plugin-rewire-1.2.0.tgz#822562d72ed2c84e47c0f95ee232c920853e9d89" integrity sha512-JBZxczHw3tScS+djy6JPLMjblchGhLI89ep15H3SyjujIzlxo5nr6Yjo7AXotdeVczeBmWs0tF8PgJWDdgzAkQ== +babel-plugin-syntax-object-rest-spread@^6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" + integrity sha1-/WU28rzhODb/o6VFjEkDpZe7O/U= + +babel-plugin-transform-es2015-modules-commonjs@^6.26.2: + version "6.26.2" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz#58a793863a9e7ca870bdc5a881117ffac27db6f3" + integrity sha512-CV9ROOHEdrjcwhIaJNBGMBCodN+1cfkwtM1SbUHmvyy35KGT7fohbpOxkE2uLz1o6odKK2Ck/tz47z+VqQfi9Q== + dependencies: + babel-plugin-transform-strict-mode "^6.24.1" + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-types "^6.26.0" + +babel-plugin-transform-strict-mode@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz#d5faf7aa578a65bbe591cf5edae04a0c67020758" + integrity sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g= + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + babel-polyfill@6.23.0: version "6.23.0" resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.23.0.tgz#8364ca62df8eafb830499f699177466c3b03499d" @@ -1321,6 +1511,27 @@ babel-polyfill@6.23.0: core-js "^2.4.0" regenerator-runtime "^0.10.0" +babel-preset-jest@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-23.2.0.tgz#8ec7a03a138f001a1a8fb1e8113652bf1a55da46" + integrity sha1-jsegOhOPABoaj7HoETZSvxpV2kY= + dependencies: + babel-plugin-jest-hoist "^23.2.0" + babel-plugin-syntax-object-rest-spread "^6.13.0" + +babel-register@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.26.0.tgz#6ed021173e2fcb486d7acb45c6009a856f647071" + integrity sha1-btAhFz4vy0htestFxgCahW9kcHE= + dependencies: + babel-core "^6.26.0" + babel-runtime "^6.26.0" + core-js "^2.5.0" + home-or-tmp "^2.0.0" + lodash "^4.17.4" + mkdirp "^0.5.1" + source-map-support "^0.4.15" + babel-runtime@^6.22.0, babel-runtime@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" @@ -1334,7 +1545,7 @@ babel-standalone@^6.26.0: resolved "https://registry.yarnpkg.com/babel-standalone/-/babel-standalone-6.26.0.tgz#15fb3d35f2c456695815ebf1ed96fe7f015b6886" integrity sha1-Ffs9NfLEVmlYFevx7Zb+fwFbaIY= -babel-template@^6.26.0: +babel-template@^6.16.0, babel-template@^6.24.1, babel-template@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02" integrity sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI= @@ -1345,7 +1556,7 @@ babel-template@^6.26.0: babylon "^6.18.0" lodash "^4.17.4" -babel-traverse@^6.26.0: +babel-traverse@^6.0.0, babel-traverse@^6.18.0, babel-traverse@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee" integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4= @@ -1360,7 +1571,7 @@ babel-traverse@^6.26.0: invariant "^2.2.2" lodash "^4.17.4" -babel-types@^6.26.0: +babel-types@^6.0.0, babel-types@^6.18.0, babel-types@^6.24.1, babel-types@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc= @@ -1418,6 +1629,13 @@ batch@0.6.1: resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" integrity sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY= +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + better-assert@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522" @@ -1505,10 +1723,10 @@ bootstrap-vue@^2.0.0-rc.11: popper.js "^1.12.9" vue-functional-data-merge "^2.0.5" -bootstrap@4.1.1, bootstrap@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.1.tgz#3aec85000fa619085da8d2e4983dfd67cf2114cb" - integrity sha512-SpiDSOcbg4J/PjVSt4ny5eY6j74VbVSjROY4Fb/WIUXBV9cnb5luyR4KnPvNoXuGnBK1T+nJIWqRsvU3yP8Mcg== +bootstrap@4.1.3, bootstrap@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.3.tgz#0eb371af2c8448e8c210411d0cb824a6409a12be" + integrity sha512-rDFIzgXcof0jDyjNosjv4Sno77X4KuPeFxG2XZZv1/Kc8DRVGVADdoQyyOVDwPqL36DDmtCQbrpMCqvpPLJQ0w== boxen@^1.2.1: version "1.3.0" @@ -1538,6 +1756,15 @@ braces@^0.1.2: dependencies: expand-range "^0.1.0" +braces@^1.8.2: + version "1.8.5" + resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" + integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc= + dependencies: + expand-range "^1.8.1" + preserve "^0.2.0" + repeat-element "^1.1.2" + braces@^2.3.0, braces@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.1.tgz#7086c913b4e5a08dbe37ac0ee6a2500c4ba691bb" @@ -1561,6 +1788,18 @@ brorand@^1.0.1: resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= +browser-process-hrtime@^0.1.2: + version "0.1.3" + resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4" + integrity sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw== + +browser-resolve@^1.11.3: + version "1.11.3" + resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.3.tgz#9b7cbb3d0f510e4cb86bdbd796124d28b5890af6" + integrity sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ== + dependencies: + resolve "1.1.7" + browserify-aes@^1.0.0, browserify-aes@^1.0.4: version "1.1.1" resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.1.1.tgz#38b7ab55edb806ff2dcda1a7f1620773a477c49f" @@ -1628,6 +1867,13 @@ browserslist@^4.1.0: electron-to-chromium "^1.3.62" node-releases "^1.0.0-alpha.11" +bser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" + integrity sha1-mseNPtXZFYBP2HrLFYvHlxR6Fxk= + dependencies: + node-int64 "^0.4.0" + buffer-from@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.0.0.tgz#4cb8832d23612589b0406e9e2956c17f06fdf531" @@ -1761,6 +2007,11 @@ callsites@^0.2.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca" integrity sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo= +callsites@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" + integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= + camelcase@^4.0.0, camelcase@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" @@ -1771,11 +2022,23 @@ caniuse-lite@^1.0.30000884: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000888.tgz#22edb50d91dd70612b5898e3b36f460600c6492f" integrity sha512-vftg+5p/lPsQGpnhSo/yBuYL36ai/cyjLvU3dOPJY1kkKrekLWIy8SLm+wzjX0hpCUdFTasC4/ZT7uqw4rKOnQ== +capture-exit@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-1.2.0.tgz#1c5fcc489fd0ab00d4f1ac7ae1072e3173fbab6f" + integrity sha1-HF/MSJ/QqwDU8ax64QcuMXP7q28= + dependencies: + rsvp "^3.3.3" + capture-stack-trace@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.0.tgz#4a6fa07399c26bba47f0b2496b4d0fb408c5550d" integrity sha1-Sm+gc5nCa7pH8LJJa00PtAjFVQ0= +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" @@ -1853,6 +2116,11 @@ chrome-trace-event@^1.0.0: dependencies: tslib "^1.9.0" +ci-info@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497" + integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A== + cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -1928,15 +2196,20 @@ clone-response@1.0.2: dependencies: mimic-response "^1.0.0" +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= -codesandbox-api@^0.0.18: - version "0.0.18" - resolved "https://registry.yarnpkg.com/codesandbox-api/-/codesandbox-api-0.0.18.tgz#56b96b37533f80d20c21861e5e477d3557e613ca" - integrity sha512-DHLR8QQpMplNDF9GDbV8EevwmF9mlMBQwiWB8bZBnP2NQQbklthqjpBwNjah8qlDgfD7vQNNcwT8uIZ24WZb7Q== +codesandbox-api@^0.0.20: + version "0.0.20" + resolved "https://registry.yarnpkg.com/codesandbox-api/-/codesandbox-api-0.0.20.tgz#174bcd76c9f31521175c6bceabc37da6b1fbc30b" + integrity sha512-jhxZzAmjCKBZad8QWMeueiQVFE87igK6F2DBOEVFFJO6jgTXT8qjuzGYepr+B8bjgo/icN7bc/2xmEMBA63s2w== codesandbox-import-util-types@^1.2.11: version "1.2.11" @@ -1984,6 +2257,13 @@ combine-lists@^1.0.0: dependencies: lodash "^4.5.0" +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" + integrity sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w== + dependencies: + delayed-stream "~1.0.0" + commander@2, commander@^2.18.0, commander@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" @@ -2145,7 +2425,7 @@ content-type@~1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== -convert-source-map@^1.1.0: +convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.1: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A== @@ -2186,7 +2466,7 @@ copy-to-clipboard@^3.0.8: dependencies: toggle-selection "^1.0.3" -core-js@^2.2.0, core-js@^2.4.0, core-js@^2.4.1: +core-js@^2.2.0, core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.0: version "2.5.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" integrity sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw== @@ -2196,7 +2476,7 @@ core-js@~2.3.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.3.0.tgz#fab83fbb0b2d8dc85fa636c4b9d34c75420c6d65" integrity sha1-+rg/uwstjchfpjbEudNMdUIMbWU= -core-util-is@~1.0.0: +core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= @@ -2329,6 +2609,18 @@ cssesc@^0.1.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" integrity sha1-yBSQPkViM3GgR3tAEJqq++6t27Q= +cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": + version "0.3.4" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.4.tgz#8cd52e8a3acfd68d3aed38ee0a640177d2f9d797" + integrity sha512-+7prCSORpXNeR4/fUP3rL+TzqtiFfhMvTd7uEqMdgPvLPt4+uzFUeufx5RHjGTACCargg/DiEt/moMQmvnfkog== + +cssstyle@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-1.1.1.tgz#18b038a9c44d65f7a8e428a653b9f6fe42faf5fb" + integrity sha512-364AI1l/M5TYcFH83JnOH/pSqgaNnKmYgKrm0didZMGKWjQB60dymwWy1rKUgL3J1ffdq9xVi2yGLHdSjjSNog== + dependencies: + cssom "0.3.x" + custom-event@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" @@ -2415,12 +2707,7 @@ d3-force@1.1.0: d3-quadtree "1" d3-timer "1" -d3-format@1, d3-format@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.2.1.tgz#4e19ecdb081a341dafaf5f555ee956bcfdbf167f" - integrity sha512-U4zRVLDXW61bmqoo+OJ/V687e1T5nVd3TAKAJKgtpZ/P1JsMgyod0y9br+mlQOryTAACdiXI3wCjuERHFNp91w== - -d3-format@1.2.2: +d3-format@1, d3-format@1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.2.2.tgz#1a39c479c8a57fe5051b2e67a3bee27061a74e7a" integrity sha512-zH9CfF/3C8zUI47nsiKfD0+AGDEuM8LwBIP7pBVpyR4l/sKkZqITmMtxRp04rwBrlshIZ17XeFAaovN3++wzkw== @@ -2492,12 +2779,7 @@ d3-scale@1.0.7, d3-scale@^1.0.7: d3-time "1" d3-time-format "2" -d3-selection@1, d3-selection@1.2.0, d3-selection@^1.1.0, d3-selection@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.2.0.tgz#1b8ec1c7cedadfb691f2ba20a4a3cfbeb71bbc88" - integrity sha512-xW2Pfcdzh1gOaoI+LGpPsLR2VpBQxuFoxvrvguK8ZmrJbPIVvfNG6pU6GNfK41D6Qz15sj61sbW/AFYuukwaLQ== - -d3-selection@1.3.0: +d3-selection@1, d3-selection@1.3.0, d3-selection@^1.1.0, d3-selection@^1.2.0: version "1.3.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.3.0.tgz#d53772382d3dc4f7507bfb28bcd2d6aed2a0ad6d" integrity sha512-qgpUOg9tl5CirdqESUAu0t9MU/t3O9klYfGfyKsXEmhyxyzLpzpeh08gaxBUTQw1uXIOkr/30Ut2YRjSSxlmHA== @@ -2554,42 +2836,6 @@ d3-zoom@1.7.1: d3-selection "1" d3-transition "1" -d3@4.12.2: - version "4.12.2" - resolved "https://registry.yarnpkg.com/d3/-/d3-4.12.2.tgz#12f775564c6a9de229f63db03446e2cb7bb56c8f" - integrity sha512-aKAlpgTmpuGeEpezB+GvPpX1x+gCMs/PHpuse6sCpkgw4Un3ZeqUobIc87eIy9adcl+wxPAnEyKyO5oulH3MOw== - dependencies: - d3-array "1.2.1" - d3-axis "1.0.8" - d3-brush "1.0.4" - d3-chord "1.0.4" - d3-collection "1.0.4" - d3-color "1.0.3" - d3-dispatch "1.0.3" - d3-drag "1.2.1" - d3-dsv "1.0.8" - d3-ease "1.0.3" - d3-force "1.1.0" - d3-format "1.2.1" - d3-geo "1.9.1" - d3-hierarchy "1.1.5" - d3-interpolate "1.1.6" - d3-path "1.0.5" - d3-polygon "1.0.3" - d3-quadtree "1.0.3" - d3-queue "3.0.7" - d3-random "1.1.0" - d3-request "1.0.6" - d3-scale "1.0.7" - d3-selection "1.2.0" - d3-shape "1.2.0" - d3-time "1.0.8" - d3-time-format "2.1.1" - d3-timer "1.0.7" - d3-transition "1.1.1" - d3-voronoi "1.1.2" - d3-zoom "1.7.1" - d3@^4.13.0: version "4.13.0" resolved "https://registry.yarnpkg.com/d3/-/d3-4.13.0.tgz#ab236ff8cf0cfc27a81e69bf2fb7518bc9b4f33d" @@ -2642,6 +2888,22 @@ dagre-layout@^0.8.8: graphlibrary "^2.2.0" lodash "^4.17.5" +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +data-urls@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe" + integrity sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ== + dependencies: + abab "^2.0.0" + whatwg-mimetype "^2.2.0" + whatwg-url "^7.0.0" + date-format@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/date-format/-/date-format-1.2.0.tgz#615e828e233dd1ab9bb9ae0950e0ceccfa6ecad8" @@ -2683,6 +2945,11 @@ debug@~3.1.0: dependencies: ms "2.0.0" +decamelize@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + decamelize@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-2.0.0.tgz#656d7bbc8094c4c788ea53c5840908c9c7d063c7" @@ -2730,6 +2997,13 @@ default-gateway@^2.6.0: execa "^0.10.0" ip-regex "^2.1.0" +default-require-extensions@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-1.0.0.tgz#f37ea15d3e13ffd9b437d33e1a75b5fb97874cb8" + integrity sha1-836hXT4T/9m0N9M+GnW1+5eHTLg= + dependencies: + strip-bom "^2.0.0" + default-require-extensions@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-2.0.0.tgz#f5f8fbb18a7d6d50b21f641f649ebb522cfe24f7" @@ -2791,6 +3065,11 @@ del@^3.0.0: pify "^3.0.0" rimraf "^2.2.8" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + delegate@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.1.2.tgz#1e1bc6f5cadda6cb6cbf7e6d05d0bcdd5712aebe" @@ -2824,11 +3103,23 @@ destroy@~1.0.4: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= +detect-indent@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" + integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg= + dependencies: + repeating "^2.0.0" + detect-libc@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= +detect-newline@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" + integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I= + detect-node@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.3.tgz#a2033c09cc8e158d37748fbde7507832bd6ce127" @@ -2926,6 +3217,13 @@ domelementtype@~1.1.1: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b" integrity sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs= +domexception@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" + integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug== + dependencies: + webidl-conversions "^4.0.2" + domhandler@^2.3.0: version "2.4.1" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.1.tgz#892e47000a99be55bbf3774ffea0561d8879c259" @@ -2973,6 +3271,21 @@ duplexify@^3.4.2, duplexify@^3.5.3: readable-stream "^2.0.0" stream-shift "^1.0.0" +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +echarts@^4.2.0-rc.2: + version "4.2.0-rc.2" + resolved "https://registry.yarnpkg.com/echarts/-/echarts-4.2.0-rc.2.tgz#6a98397aafa81b65cbf0bc15d9afdbfb244df91e" + integrity sha512-5Y4Kyi4eNsRM9Cnl7Q8C6PFVjznBJv1VIiMm/VSQ9zyqeo+ce1695GqUd9v4zfVx+Ow1gnwMJX67h0FNvarScw== + dependencies: + zrender "4.0.5" + editions@^1.3.3: version "1.3.4" resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.4.tgz#3662cb592347c3168eb8e498a0ff73271d67f50b" @@ -3181,6 +3494,18 @@ escodegen@1.8.x: optionalDependencies: source-map "~0.2.0" +escodegen@^1.9.1: + version "1.11.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.11.0.tgz#b27a9389481d5bfd5bec76f7bb1eb3f8f4556589" + integrity sha512-IeMV45ReixHS53K/OmfKAIztN/igDHzTJUhZM3k1jMhIZWjk45SMwAtBsEXiJp3vSPmTcu6CXn7mDvFHRN66fw== + dependencies: + esprima "^3.1.3" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + eslint-config-airbnb-base@^13.1.0: version "13.1.0" resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-13.1.0.tgz#b5a1b480b80dfad16433d6c4ad84e6605052c05c" @@ -3197,6 +3522,15 @@ eslint-config-prettier@^3.1.0: dependencies: get-stdin "^6.0.0" +eslint-import-resolver-jest@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-jest/-/eslint-import-resolver-jest-2.1.1.tgz#78c1934e3b5b77283326f036e089cc3b9fae6346" + integrity sha512-yzzZFN37CaMaCjmUZ4Zo7Pw5qCG/hDklVzxIeHYJZkbcdg0sL5MeLaOG8s3ndVBvv1PSdSq4jfkY0QXt/KPbTg== + dependencies: + find-root "^1.0.0" + micromatch "^3.1.6" + resolve "^1.5.0" + eslint-import-resolver-node@^0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz#58f15fb839b8d0576ca980413476aab2472db66a" @@ -3267,6 +3601,11 @@ eslint-plugin-jasmine@^2.10.1: resolved "https://registry.yarnpkg.com/eslint-plugin-jasmine/-/eslint-plugin-jasmine-2.10.1.tgz#5733b709e751f4bc40e31e1c16989bd2cdfbec97" integrity sha1-VzO3CedR9LxA4x4cFpib0s377Jc= +eslint-plugin-jest@^22.1.0: + version "22.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.1.0.tgz#9a4dfa3367563e8301560a7fb92ec309096dbca3" + integrity sha512-WcQd5LxEoAS20zuWEAd8CX0pVC+gGInZPcsoYvK5w7BrEJNmltyTxYYh1r0ct4GsahD2GvNySlcTcLtK2amFZA== + eslint-plugin-promise@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.0.1.tgz#2d074b653f35a23d1ba89d8e976a985117d1c6a2" @@ -3367,6 +3706,11 @@ esprima@2.7.x, esprima@^2.7.1: resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" integrity sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE= +esprima@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" + integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM= + esprima@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -3391,7 +3735,7 @@ estraverse@^1.9.1: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-1.9.3.tgz#af67f2dc922582415950926091a4005d29c9bb44" integrity sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q= -estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1: +estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM= @@ -3449,6 +3793,13 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" +exec-sh@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36" + integrity sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw== + dependencies: + merge "^1.2.0" + execa@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50" @@ -3475,6 +3826,11 @@ execa@^0.7.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= + expand-braces@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/expand-braces/-/expand-braces-0.1.2.tgz#488b1d1d2451cb3d3a6b192cfc030f44c5855fea" @@ -3484,6 +3840,13 @@ expand-braces@^0.1.1: array-unique "^0.2.1" braces "^0.1.2" +expand-brackets@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" + integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s= + dependencies: + is-posix-bracket "^0.1.0" + expand-brackets@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" @@ -3505,6 +3868,25 @@ expand-range@^0.1.0: is-number "^0.1.1" repeat-string "^0.2.2" +expand-range@^1.8.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" + integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc= + dependencies: + fill-range "^2.1.0" + +expect@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-23.6.0.tgz#1e0c8d3ba9a581c87bd71fb9bc8862d443425f98" + integrity sha512-dgSoOHgmtn/aDGRVFWclQyPDKl2CQRq0hmIEoUAuQs/2rn2NcvCWcSCovm6BLeuB/7EZuLGu2QfnR+qRt5OM4w== + dependencies: + ansi-styles "^3.2.0" + jest-diff "^23.6.0" + jest-get-type "^22.1.0" + jest-matcher-utils "^23.6.0" + jest-message-util "^23.4.0" + jest-regex-util "^23.3.0" + exports-loader@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/exports-loader/-/exports-loader-0.7.0.tgz#84881c784dea6036b8e1cd1dac3da9b6409e21a5" @@ -3564,7 +3946,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@^3.0.0: +extend@^3.0.0, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -3587,6 +3969,13 @@ external-editor@^3.0.0: iconv-lite "^0.4.22" tmp "^0.0.33" +extglob@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" + integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE= + dependencies: + is-extglob "^1.0.0" + extglob@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" @@ -3601,6 +3990,16 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= + fast-deep-equal@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" @@ -3635,6 +4034,13 @@ faye-websocket@~0.11.1: dependencies: websocket-driver ">=0.5.1" +fb-watchman@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58" + integrity sha1-VOmr99+i8mzZsWNsWIwa/AXeXVg= + dependencies: + bser "^2.0.0" + figgy-pudding@^3.1.0, figgy-pudding@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" @@ -3663,7 +4069,12 @@ file-loader@^2.0.0: loader-utils "^1.0.2" schema-utils "^1.0.0" -fileset@^2.0.3: +filename-regex@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" + integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY= + +fileset@^2.0.2, fileset@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/fileset/-/fileset-2.0.3.tgz#8e7548a96d3cc2327ee5e674168723a333bba2a0" integrity sha1-jnVIqW08wjJ+5eZ0FocjozO7oqA= @@ -3676,6 +4087,17 @@ filesize@^3.6.1: resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" integrity sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg== +fill-range@^2.1.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565" + integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q== + dependencies: + is-number "^2.1.0" + isobject "^2.0.0" + randomatic "^3.0.0" + repeat-element "^1.1.2" + repeat-string "^1.5.2" + fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -3730,7 +4152,7 @@ find-cache-dir@^2.0.0: make-dir "^1.0.0" pkg-dir "^3.0.0" -find-root@^1.1.0: +find-root@^1.0.0, find-root@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== @@ -3782,11 +4204,32 @@ follow-redirects@^1.2.5: dependencies: debug "^3.1.0" -for-in@^1.0.2: +for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= +for-own@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" + integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4= + dependencies: + for-in "^1.0.1" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + formdata-polyfill@^3.0.11: version "3.0.11" resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-3.0.11.tgz#c82b4b4bea3356c0a6752219e54ce1edb2a7fb5b" @@ -3851,7 +4294,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= -fsevents@^1.2.2: +fsevents@^1.2.2, fsevents@^1.2.3: version "1.2.4" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426" integrity sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg== @@ -3908,6 +4351,13 @@ get-value@^2.0.3, get-value@^2.0.6: resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + gettext-extractor-vue@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/gettext-extractor-vue/-/gettext-extractor-vue-4.0.1.tgz#69d2737eb8f1938803ffcf9317133ed59fb2372f" @@ -3930,6 +4380,21 @@ gettext-extractor@^3.3.2: pofile "^1" typescript "^2" +glob-base@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" + integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q= + dependencies: + glob-parent "^2.0.0" + is-glob "^2.0.0" + +glob-parent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" + integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg= + dependencies: + is-glob "^2.0.0" + glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -4084,6 +4549,11 @@ graphql@^14.0.2: dependencies: iterall "^1.2.2" +growly@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" + integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= + gzip-size@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.0.0.tgz#a55ecd99222f4c48fd8c01c625ce3b349d0a0e80" @@ -4097,7 +4567,7 @@ handle-thing@^1.2.5: resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4" integrity sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ= -handlebars@^4.0.1, handlebars@^4.0.11: +handlebars@^4.0.1, handlebars@^4.0.11, handlebars@^4.0.3: version "4.0.12" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.12.tgz#2c15c8a96d46da5e266700518ba8cb8d919d5bc5" integrity sha512-RhmTekP+FZL+XNhwS1Wf+bTTZpdLougwt5pcgA1tuz6Jcx0fpH/7z0qd71RKnZHBCxIRBHfBOnio4gViPemNzA== @@ -4108,6 +4578,19 @@ handlebars@^4.0.1, handlebars@^4.0.11: optionalDependencies: uglify-js "^3.1.4" +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + +har-validator@~5.1.0: + version "5.1.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" + integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g== + dependencies: + ajv "^6.5.5" + har-schema "^2.0.0" + has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" @@ -4244,6 +4727,14 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +home-or-tmp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" + integrity sha1-42w/LSyufXRqhX440Y1fMqeILbg= + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.1" + hoopy@^0.1.2: version "0.1.4" resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d" @@ -4264,6 +4755,13 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" +html-encoding-sniffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8" + integrity sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw== + dependencies: + whatwg-encoding "^1.0.1" + html-entities@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.0.tgz#41948caf85ce82fed36e4e6a0ed371a6664379e2" @@ -4319,6 +4817,15 @@ http-proxy@^1.13.0, http-proxy@^1.16.2: eventemitter3 "1.x.x" requires-port "1.x.x" +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" @@ -4336,6 +4843,13 @@ iconv-lite@0.4.19: resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" integrity sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ== +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" @@ -4511,13 +5025,18 @@ into-stream@^3.1.0: from2 "^2.1.1" p-is-promise "^1.1.0" -invariant@^2.2.2: +invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== dependencies: loose-envify "^1.0.0" +invert-kv@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" + integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= + invert-kv@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" @@ -4581,6 +5100,13 @@ is-callable@^1.1.1, is-callable@^1.1.3: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA== +is-ci@^1.0.10: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c" + integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg== + dependencies: + ci-info "^1.5.0" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -4618,6 +5144,18 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" +is-dotfile@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" + integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE= + +is-equal-shallow@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" + integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ= + dependencies: + is-primitive "^2.0.0" + is-extendable@^0.1.0, is-extendable@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" @@ -4630,11 +5168,23 @@ is-extendable@^1.0.1: dependencies: is-plain-object "^2.0.4" +is-extglob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" + integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA= + is-extglob@^2.1.0, is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= +is-finite@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" + integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko= + dependencies: + number-is-nan "^1.0.0" + is-fullwidth-code-point@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" @@ -4647,6 +5197,18 @@ is-fullwidth-code-point@^2.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= +is-generator-fn@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-1.0.0.tgz#969d49e1bb3329f6bb7f09089be26578b2ddd46a" + integrity sha1-lp1J4bszKfa7fwkIm+JleLLd1Go= + +is-glob@^2.0.0, is-glob@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" + integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM= + dependencies: + is-extglob "^1.0.0" + is-glob@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" @@ -4679,6 +5241,13 @@ is-number@^0.1.1: resolved "https://registry.yarnpkg.com/is-number/-/is-number-0.1.1.tgz#69a7af116963d47206ec9bd9b48a14216f1e3806" integrity sha1-aaevEWlj1HIG7JvZtIoUIW8eOAY= +is-number@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" + integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8= + dependencies: + kind-of "^3.0.2" + is-number@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" @@ -4739,6 +5308,16 @@ is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" +is-posix-bracket@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" + integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q= + +is-primitive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" + integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU= + is-promise@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" @@ -4783,6 +5362,16 @@ is-symbol@^1.0.1: dependencies: has-symbols "^1.0.0" +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +is-utf8@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" + integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= + is-windows@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" @@ -4825,6 +5414,28 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +istanbul-api@^1.3.1: + version "1.3.7" + resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.3.7.tgz#a86c770d2b03e11e3f778cd7aedd82d2722092aa" + integrity sha512-4/ApBnMVeEPG3EkSzcw25wDe4N66wxwn+KKn6b47vyek8Xb3NBAcg4xfuQbS7BqcZuTX4wxfD5lVagdggR3gyA== + dependencies: + async "^2.1.4" + fileset "^2.0.2" + istanbul-lib-coverage "^1.2.1" + istanbul-lib-hook "^1.2.2" + istanbul-lib-instrument "^1.10.2" + istanbul-lib-report "^1.1.5" + istanbul-lib-source-maps "^1.2.6" + istanbul-reports "^1.5.1" + js-yaml "^3.7.0" + mkdirp "^0.5.1" + once "^1.4.0" + istanbul-api@^2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-2.0.6.tgz#cd7b33ee678f6c01531d05f5e567ebbcd25f8ecc" @@ -4843,11 +5454,23 @@ istanbul-api@^2.0.5: make-dir "^1.3.0" once "^1.4.0" +istanbul-lib-coverage@^1.2.0, istanbul-lib-coverage@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz#ccf7edcd0a0bb9b8f729feeb0930470f9af664f0" + integrity sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ== + istanbul-lib-coverage@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#2aee0e073ad8c5f6a0b00e0dfbf52b4667472eda" integrity sha512-nPvSZsVlbG9aLhZYaC3Oi1gT/tpyo3Yt5fNyf6NmcKIayz4VV/txxJFFKAK/gU4dcNn8ehsanBbVHVl0+amOLA== +istanbul-lib-hook@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.2.2.tgz#bc6bf07f12a641fbf1c85391d0daa8f0aea6bf86" + integrity sha512-/Jmq7Y1VeHnZEQ3TL10VHyb564mn6VrQXHchON9Jf/AEcmQ3ZIiyD1BVzNOKTZf/G3gE+kiGK6SmpF9y3qGPLw== + dependencies: + append-transform "^0.4.0" + istanbul-lib-hook@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-2.0.1.tgz#918a57b75a0f951d552a08487ca1fa5336433d72" @@ -4855,6 +5478,19 @@ istanbul-lib-hook@^2.0.1: dependencies: append-transform "^1.0.0" +istanbul-lib-instrument@^1.10.1, istanbul-lib-instrument@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.10.2.tgz#1f55ed10ac3c47f2bdddd5307935126754d0a9ca" + integrity sha512-aWHxfxDqvh/ZlxR8BBaEPVSWDPUkGD63VjGQn3jcw8jCp7sHEMKcrj4xfJn/ABzdMEHiQNyvDQhqm5o8+SQg7A== + dependencies: + babel-generator "^6.18.0" + babel-template "^6.16.0" + babel-traverse "^6.18.0" + babel-types "^6.18.0" + babylon "^6.18.0" + istanbul-lib-coverage "^1.2.1" + semver "^5.3.0" + istanbul-lib-instrument@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-3.0.0.tgz#b5f066b2a161f75788be17a9d556f40a0cf2afc9" @@ -4868,6 +5504,16 @@ istanbul-lib-instrument@^3.0.0: istanbul-lib-coverage "^2.0.1" semver "^5.5.0" +istanbul-lib-report@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-1.1.5.tgz#f2a657fc6282f96170aaf281eb30a458f7f4170c" + integrity sha512-UsYfRMoi6QO/doUshYNqcKJqVmFe9w51GZz8BS3WB0lYxAllQYklka2wP9+dGZeHYaWIdcXUx8JGdbqaoXRXzw== + dependencies: + istanbul-lib-coverage "^1.2.1" + mkdirp "^0.5.1" + path-parse "^1.0.5" + supports-color "^3.1.2" + istanbul-lib-report@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-2.0.2.tgz#430a2598519113e1da7af274ba861bd42dd97535" @@ -4877,6 +5523,17 @@ istanbul-lib-report@^2.0.2: make-dir "^1.3.0" supports-color "^5.4.0" +istanbul-lib-source-maps@^1.2.4, istanbul-lib-source-maps@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.6.tgz#37b9ff661580f8fca11232752ee42e08c6675d8f" + integrity sha512-TtbsY5GIHgbMsMiRw35YBHGpZ1DVFEO19vxxeiDMYaeOFOCzfnYVxvl6pOUIZR4dtPhAGpSMup8OyF8ubsaqEg== + dependencies: + debug "^3.1.0" + istanbul-lib-coverage "^1.2.1" + mkdirp "^0.5.1" + rimraf "^2.6.1" + source-map "^0.5.3" + istanbul-lib-source-maps@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-2.0.1.tgz#ce8b45131d8293fdeaa732f4faf1852d13d0a97e" @@ -4888,6 +5545,13 @@ istanbul-lib-source-maps@^2.0.1: rimraf "^2.6.2" source-map "^0.6.1" +istanbul-reports@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-1.5.1.tgz#97e4dbf3b515e8c484caea15d6524eebd3ff4e1a" + integrity sha512-+cfoZ0UXzWjhAdzosCPP3AN8vvef8XDkWtTfgaN+7L3YTpNYITnCaEkceo5SEYy644VkHka/P1FvkWvrG/rrJw== + dependencies: + handlebars "^4.0.3" + istanbul-reports@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-2.0.1.tgz#fb8d6ea850701a3984350b977a969e9a556116a7" @@ -4959,6 +5623,334 @@ jed@^1.1.1: resolved "https://registry.yarnpkg.com/jed/-/jed-1.1.1.tgz#7a549bbd9ffe1585b0cd0a191e203055bee574b4" integrity sha1-elSbvZ/+FYWwzQoZHiAwVb7ldLQ= +jest-changed-files@^23.4.2: + version "23.4.2" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-23.4.2.tgz#1eed688370cd5eebafe4ae93d34bb3b64968fe83" + integrity sha512-EyNhTAUWEfwnK0Is/09LxoqNDOn7mU7S3EHskG52djOFS/z+IT0jT3h3Ql61+dklcG7bJJitIWEMB4Sp1piHmA== + dependencies: + throat "^4.0.0" + +jest-cli@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-23.6.0.tgz#61ab917744338f443ef2baa282ddffdd658a5da4" + integrity sha512-hgeD1zRUp1E1zsiyOXjEn4LzRLWdJBV//ukAHGlx6s5mfCNJTbhbHjgxnDUXA8fsKWN/HqFFF6X5XcCwC/IvYQ== + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.1" + exit "^0.1.2" + glob "^7.1.2" + graceful-fs "^4.1.11" + import-local "^1.0.0" + is-ci "^1.0.10" + istanbul-api "^1.3.1" + istanbul-lib-coverage "^1.2.0" + istanbul-lib-instrument "^1.10.1" + istanbul-lib-source-maps "^1.2.4" + jest-changed-files "^23.4.2" + jest-config "^23.6.0" + jest-environment-jsdom "^23.4.0" + jest-get-type "^22.1.0" + jest-haste-map "^23.6.0" + jest-message-util "^23.4.0" + jest-regex-util "^23.3.0" + jest-resolve-dependencies "^23.6.0" + jest-runner "^23.6.0" + jest-runtime "^23.6.0" + jest-snapshot "^23.6.0" + jest-util "^23.4.0" + jest-validate "^23.6.0" + jest-watcher "^23.4.0" + jest-worker "^23.2.0" + micromatch "^2.3.11" + node-notifier "^5.2.1" + prompts "^0.1.9" + realpath-native "^1.0.0" + rimraf "^2.5.4" + slash "^1.0.0" + string-length "^2.0.0" + strip-ansi "^4.0.0" + which "^1.2.12" + yargs "^11.0.0" + +jest-config@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-23.6.0.tgz#f82546a90ade2d8c7026fbf6ac5207fc22f8eb1d" + integrity sha512-i8V7z9BeDXab1+VNo78WM0AtWpBRXJLnkT+lyT+Slx/cbP5sZJ0+NDuLcmBE5hXAoK0aUp7vI+MOxR+R4d8SRQ== + dependencies: + babel-core "^6.0.0" + babel-jest "^23.6.0" + chalk "^2.0.1" + glob "^7.1.1" + jest-environment-jsdom "^23.4.0" + jest-environment-node "^23.4.0" + jest-get-type "^22.1.0" + jest-jasmine2 "^23.6.0" + jest-regex-util "^23.3.0" + jest-resolve "^23.6.0" + jest-util "^23.4.0" + jest-validate "^23.6.0" + micromatch "^2.3.11" + pretty-format "^23.6.0" + +jest-diff@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-23.6.0.tgz#1500f3f16e850bb3d71233408089be099f610c7d" + integrity sha512-Gz9l5Ov+X3aL5L37IT+8hoCUsof1CVYBb2QEkOupK64XyRR3h+uRpYIm97K7sY8diFxowR8pIGEdyfMKTixo3g== + dependencies: + chalk "^2.0.1" + diff "^3.2.0" + jest-get-type "^22.1.0" + pretty-format "^23.6.0" + +jest-docblock@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-23.2.0.tgz#f085e1f18548d99fdd69b20207e6fd55d91383a7" + integrity sha1-8IXh8YVI2Z/dabICB+b9VdkTg6c= + dependencies: + detect-newline "^2.1.0" + +jest-each@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-23.6.0.tgz#ba0c3a82a8054387016139c733a05242d3d71575" + integrity sha512-x7V6M/WGJo6/kLoissORuvLIeAoyo2YqLOoCDkohgJ4XOXSqOtyvr8FbInlAWS77ojBsZrafbozWoKVRdtxFCg== + dependencies: + chalk "^2.0.1" + pretty-format "^23.6.0" + +jest-environment-jsdom@^23.4.0: + version "23.4.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-23.4.0.tgz#056a7952b3fea513ac62a140a2c368c79d9e6023" + integrity sha1-BWp5UrP+pROsYqFAosNox52eYCM= + dependencies: + jest-mock "^23.2.0" + jest-util "^23.4.0" + jsdom "^11.5.1" + +jest-environment-node@^23.4.0: + version "23.4.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-23.4.0.tgz#57e80ed0841dea303167cce8cd79521debafde10" + integrity sha1-V+gO0IQd6jAxZ8zozXlSHeuv3hA= + dependencies: + jest-mock "^23.2.0" + jest-util "^23.4.0" + +jest-get-type@^22.1.0: + version "22.4.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-22.4.3.tgz#e3a8504d8479342dd4420236b322869f18900ce4" + integrity sha512-/jsz0Y+V29w1chdXVygEKSz2nBoHoYqNShPe+QgxSNjAuP1i8+k4LbQNrfoliKej0P45sivkSCh7yiD6ubHS3w== + +jest-haste-map@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-23.6.0.tgz#2e3eb997814ca696d62afdb3f2529f5bbc935e16" + integrity sha512-uyNhMyl6dr6HaXGHp8VF7cK6KpC6G9z9LiMNsst+rJIZ8l7wY0tk8qwjPmEghczojZ2/ZhtEdIabZ0OQRJSGGg== + dependencies: + fb-watchman "^2.0.0" + graceful-fs "^4.1.11" + invariant "^2.2.4" + jest-docblock "^23.2.0" + jest-serializer "^23.0.1" + jest-worker "^23.2.0" + micromatch "^2.3.11" + sane "^2.0.0" + +jest-jasmine2@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-23.6.0.tgz#840e937f848a6c8638df24360ab869cc718592e0" + integrity sha512-pe2Ytgs1nyCs8IvsEJRiRTPC0eVYd8L/dXJGU08GFuBwZ4sYH/lmFDdOL3ZmvJR8QKqV9MFuwlsAi/EWkFUbsQ== + dependencies: + babel-traverse "^6.0.0" + chalk "^2.0.1" + co "^4.6.0" + expect "^23.6.0" + is-generator-fn "^1.0.0" + jest-diff "^23.6.0" + jest-each "^23.6.0" + jest-matcher-utils "^23.6.0" + jest-message-util "^23.4.0" + jest-snapshot "^23.6.0" + jest-util "^23.4.0" + pretty-format "^23.6.0" + +jest-junit@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/jest-junit/-/jest-junit-5.2.0.tgz#980401db7aa69999cf117c6d740a8135c22ae379" + integrity sha512-Mdg0Qpdh1Xm/FA1B/mcLlmEmlr3XzH5pZg7MvcAwZhjHijPRd1z/UwYwkwNHmCV7o4ZOWCf77nLu7ZkhHHrtJg== + dependencies: + jest-config "^23.6.0" + jest-validate "^23.0.1" + mkdirp "^0.5.1" + strip-ansi "^4.0.0" + xml "^1.0.1" + +jest-leak-detector@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-23.6.0.tgz#e4230fd42cf381a1a1971237ad56897de7e171de" + integrity sha512-f/8zA04rsl1Nzj10HIyEsXvYlMpMPcy0QkQilVZDFOaPbv2ur71X5u2+C4ZQJGyV/xvVXtCCZ3wQ99IgQxftCg== + dependencies: + pretty-format "^23.6.0" + +jest-matcher-utils@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz#726bcea0c5294261a7417afb6da3186b4b8cac80" + integrity sha512-rosyCHQfBcol4NsckTn01cdelzWLU9Cq7aaigDf8VwwpIRvWE/9zLgX2bON+FkEW69/0UuYslUe22SOdEf2nog== + dependencies: + chalk "^2.0.1" + jest-get-type "^22.1.0" + pretty-format "^23.6.0" + +jest-message-util@^23.4.0: + version "23.4.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-23.4.0.tgz#17610c50942349508d01a3d1e0bda2c079086a9f" + integrity sha1-F2EMUJQjSVCNAaPR4L2iwHkIap8= + dependencies: + "@babel/code-frame" "^7.0.0-beta.35" + chalk "^2.0.1" + micromatch "^2.3.11" + slash "^1.0.0" + stack-utils "^1.0.1" + +jest-mock@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-23.2.0.tgz#ad1c60f29e8719d47c26e1138098b6d18b261134" + integrity sha1-rRxg8p6HGdR8JuETgJi20YsmETQ= + +jest-regex-util@^23.3.0: + version "23.3.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-23.3.0.tgz#5f86729547c2785c4002ceaa8f849fe8ca471bc5" + integrity sha1-X4ZylUfCeFxAAs6qj4Sf6MpHG8U= + +jest-resolve-dependencies@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-23.6.0.tgz#b4526af24c8540d9a3fab102c15081cf509b723d" + integrity sha512-EkQWkFWjGKwRtRyIwRwI6rtPAEyPWlUC2MpzHissYnzJeHcyCn1Hc8j7Nn1xUVrS5C6W5+ZL37XTem4D4pLZdA== + dependencies: + jest-regex-util "^23.3.0" + jest-snapshot "^23.6.0" + +jest-resolve@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-23.6.0.tgz#cf1d1a24ce7ee7b23d661c33ba2150f3aebfa0ae" + integrity sha512-XyoRxNtO7YGpQDmtQCmZjum1MljDqUCob7XlZ6jy9gsMugHdN2hY4+Acz9Qvjz2mSsOnPSH7skBmDYCHXVZqkA== + dependencies: + browser-resolve "^1.11.3" + chalk "^2.0.1" + realpath-native "^1.0.0" + +jest-runner@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-23.6.0.tgz#3894bd219ffc3f3cb94dc48a4170a2e6f23a5a38" + integrity sha512-kw0+uj710dzSJKU6ygri851CObtCD9cN8aNkg8jWJf4ewFyEa6kwmiH/r/M1Ec5IL/6VFa0wnAk6w+gzUtjJzA== + dependencies: + exit "^0.1.2" + graceful-fs "^4.1.11" + jest-config "^23.6.0" + jest-docblock "^23.2.0" + jest-haste-map "^23.6.0" + jest-jasmine2 "^23.6.0" + jest-leak-detector "^23.6.0" + jest-message-util "^23.4.0" + jest-runtime "^23.6.0" + jest-util "^23.4.0" + jest-worker "^23.2.0" + source-map-support "^0.5.6" + throat "^4.0.0" + +jest-runtime@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-23.6.0.tgz#059e58c8ab445917cd0e0d84ac2ba68de8f23082" + integrity sha512-ycnLTNPT2Gv+TRhnAYAQ0B3SryEXhhRj1kA6hBPSeZaNQkJ7GbZsxOLUkwg6YmvWGdX3BB3PYKFLDQCAE1zNOw== + dependencies: + babel-core "^6.0.0" + babel-plugin-istanbul "^4.1.6" + chalk "^2.0.1" + convert-source-map "^1.4.0" + exit "^0.1.2" + fast-json-stable-stringify "^2.0.0" + graceful-fs "^4.1.11" + jest-config "^23.6.0" + jest-haste-map "^23.6.0" + jest-message-util "^23.4.0" + jest-regex-util "^23.3.0" + jest-resolve "^23.6.0" + jest-snapshot "^23.6.0" + jest-util "^23.4.0" + jest-validate "^23.6.0" + micromatch "^2.3.11" + realpath-native "^1.0.0" + slash "^1.0.0" + strip-bom "3.0.0" + write-file-atomic "^2.1.0" + yargs "^11.0.0" + +jest-serializer@^23.0.1: + version "23.0.1" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-23.0.1.tgz#a3776aeb311e90fe83fab9e533e85102bd164165" + integrity sha1-o3dq6zEekP6D+rnlM+hRAr0WQWU= + +jest-snapshot@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-23.6.0.tgz#f9c2625d1b18acda01ec2d2b826c0ce58a5aa17a" + integrity sha512-tM7/Bprftun6Cvj2Awh/ikS7zV3pVwjRYU2qNYS51VZHgaAMBs5l4o/69AiDHhQrj5+LA2Lq4VIvK7zYk/bswg== + dependencies: + babel-types "^6.0.0" + chalk "^2.0.1" + jest-diff "^23.6.0" + jest-matcher-utils "^23.6.0" + jest-message-util "^23.4.0" + jest-resolve "^23.6.0" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + pretty-format "^23.6.0" + semver "^5.5.0" + +jest-util@^23.4.0: + version "23.4.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-23.4.0.tgz#4d063cb927baf0a23831ff61bec2cbbf49793561" + integrity sha1-TQY8uSe68KI4Mf9hvsLLv0l5NWE= + dependencies: + callsites "^2.0.0" + chalk "^2.0.1" + graceful-fs "^4.1.11" + is-ci "^1.0.10" + jest-message-util "^23.4.0" + mkdirp "^0.5.1" + slash "^1.0.0" + source-map "^0.6.0" + +jest-validate@^23.0.1, jest-validate@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-23.6.0.tgz#36761f99d1ed33fcd425b4e4c5595d62b6597474" + integrity sha512-OFKapYxe72yz7agrDAWi8v2WL8GIfVqcbKRCLbRG9PAxtzF9b1SEDdTpytNDN12z2fJynoBwpMpvj2R39plI2A== + dependencies: + chalk "^2.0.1" + jest-get-type "^22.1.0" + leven "^2.1.0" + pretty-format "^23.6.0" + +jest-watcher@^23.4.0: + version "23.4.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-23.4.0.tgz#d2e28ce74f8dad6c6afc922b92cabef6ed05c91c" + integrity sha1-0uKM50+NrWxq/JIrksq+9u0FyRw= + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.1" + string-length "^2.0.0" + +jest-worker@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-23.2.0.tgz#faf706a8da36fae60eb26957257fa7b5d8ea02b9" + integrity sha1-+vcGqNo2+uYOsmlXJX+ntdjqArk= + dependencies: + merge-stream "^1.0.1" + +jest@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-23.6.0.tgz#ad5835e923ebf6e19e7a1d7529a432edfee7813d" + integrity sha512-lWzcd+HSiqeuxyhG+EnZds6iO3Y3ZEnMrfZq/OTGvF/C+Z4fPMCdhWTGSAiO2Oym9rbEXfwddHhh6jqrTF3+Lw== + dependencies: + import-local "^1.0.0" + jest-cli "^23.6.0" + jquery-ujs@1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/jquery-ujs/-/jquery-ujs-1.2.2.tgz#6a8ef1020e6b6dda385b90a4bddc128c21c56397" @@ -5006,7 +5998,7 @@ js-tokens@^3.0.2: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@3.x, js-yaml@^3.12.0: +js-yaml@3.x, js-yaml@^3.12.0, js-yaml@^3.7.0: version "3.12.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" integrity sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A== @@ -5014,6 +6006,48 @@ js-yaml@3.x, js-yaml@^3.12.0: argparse "^1.0.7" esprima "^4.0.0" +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +jsdom@^11.5.1: + version "11.12.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.12.0.tgz#1a80d40ddd378a1de59656e9e6dc5a3ba8657bc8" + integrity sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw== + dependencies: + abab "^2.0.0" + acorn "^5.5.3" + acorn-globals "^4.1.0" + array-equal "^1.0.0" + cssom ">= 0.3.2 < 0.4.0" + cssstyle "^1.0.0" + data-urls "^1.0.0" + domexception "^1.0.1" + escodegen "^1.9.1" + html-encoding-sniffer "^1.0.2" + left-pad "^1.3.0" + nwsapi "^2.0.7" + parse5 "4.0.0" + pn "^1.1.0" + request "^2.87.0" + request-promise-native "^1.0.5" + sax "^1.2.4" + symbol-tree "^3.2.2" + tough-cookie "^2.3.4" + w3c-hr-time "^1.0.1" + webidl-conversions "^4.0.2" + whatwg-encoding "^1.0.3" + whatwg-mimetype "^2.1.0" + whatwg-url "^6.4.1" + ws "^5.2.0" + xml-name-validator "^3.0.0" + +jsesc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" + integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s= + jsesc@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.1.tgz#e421a2a8e20d6b0819df28908f782526b96dd1fe" @@ -5039,21 +6073,41 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + json3@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" integrity sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE= -json5@^0.5.0: +json5@^0.5.0, json5@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE= +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + jszip-utils@^0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/jszip-utils/-/jszip-utils-0.0.2.tgz#457d5cbca60a1c2e0706e9da2b544e8e7bc50bf8" @@ -5202,6 +6256,11 @@ kind-of@^6.0.0, kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== +kleur@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-2.0.2.tgz#b704f4944d95e255d038f0cb05fb8a602c55a300" + integrity sha512-77XF9iTllATmG9lSlIv0qdQ2BQ/h9t0bJllHlbvsQ0zUWfU7Yi0S8L5JXzPZgkefIiajLmBJJ4BsMJmqcf7oxQ== + latest-version@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15" @@ -5216,6 +6275,13 @@ lazy-cache@^2.0.2: dependencies: set-getter "^0.1.0" +lcid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" + integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= + dependencies: + invert-kv "^1.0.0" + lcid@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" @@ -5223,6 +6289,16 @@ lcid@^2.0.0: dependencies: invert-kv "^2.0.0" +left-pad@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== + +leven@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580" + integrity sha1-wuep93IJTe6dNCAq6KzORoeHVYA= + levn@^0.3.0, levn@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" @@ -5238,6 +6314,17 @@ lie@~3.1.0: dependencies: immediate "~3.0.5" +load-json-file@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" + integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA= + dependencies: + graceful-fs "^4.1.2" + parse-json "^2.2.0" + pify "^2.0.0" + pinkie-promise "^2.0.0" + strip-bom "^2.0.0" + load-json-file@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" @@ -5333,6 +6420,11 @@ lodash.snakecase@4.1.1: resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" integrity sha1-OdcUo1NXFHg3rv1ktdy7Fr7Nj40= +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= + lodash.startcase@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.startcase/-/lodash.startcase-4.4.0.tgz#9436e34ed26093ed7ffae1936144350915d9add8" @@ -5343,7 +6435,7 @@ lodash.upperfirst@4.3.1: resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" integrity sha1-E2Xt9DFIBIHvDRxolXpe2Z1J984= -lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0, lodash@^4.5.0: +lodash@^4.13.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0, lodash@^4.5.0: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== @@ -5408,6 +6500,13 @@ make-dir@^1.0.0, make-dir@^1.3.0: dependencies: pify "^3.0.0" +makeerror@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" + integrity sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw= + dependencies: + tmpl "1.0.x" + mamacro@^0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/mamacro/-/mamacro-0.0.3.tgz#ad2c9576197c9f1abf308d0787865bd975a3f3e4" @@ -5447,6 +6546,11 @@ match-at@^0.1.1: resolved "https://registry.yarnpkg.com/match-at/-/match-at-0.1.1.tgz#25d040d291777704d5e6556bbb79230ec2de0540" integrity sha512-h4Yd392z9mST+dzc+yjuybOGFNOZjmXIPKWjxBd1Bb23r4SmDOsk2NYCU2BMUBGbSpZqwVsZYNq26QS3xfaT3Q== +math-random@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.1.tgz#8b3aac588b8a66e4975e3cdea67f7bb329601fac" + integrity sha1-izqsWIuKZuSXXjzepn97sylgH6w= + md5.js@^1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d" @@ -5460,6 +6564,13 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +mem@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" + integrity sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y= + dependencies: + mimic-fn "^1.0.0" + mem@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/mem/-/mem-4.0.0.tgz#6437690d9471678f6cc83659c00cbafcd6b0cdaf" @@ -5494,6 +6605,18 @@ merge-source-map@^1.1.0: dependencies: source-map "^0.6.1" +merge-stream@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1" + integrity sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE= + dependencies: + readable-stream "^2.0.1" + +merge@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145" + integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ== + mermaid@^8.0.0-rc.8: version "8.0.0-rc.8" resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-8.0.0-rc.8.tgz#74ed54d0d46e9ee71c4db2730b2d83d516a21e72" @@ -5513,7 +6636,26 @@ methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= -micromatch@^3.1.4, micromatch@^3.1.8, micromatch@^3.1.9: +micromatch@^2.3.11: + version "2.3.11" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" + integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU= + dependencies: + arr-diff "^2.0.0" + array-unique "^0.2.1" + braces "^1.8.2" + expand-brackets "^0.1.4" + extglob "^0.3.1" + filename-regex "^2.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.1" + kind-of "^3.0.2" + normalize-path "^2.0.1" + object.omit "^2.0.0" + parse-glob "^3.0.4" + regex-cache "^0.4.2" + +micromatch@^3.1.4, micromatch@^3.1.6, micromatch@^3.1.8, micromatch@^3.1.9: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== @@ -5545,7 +6687,7 @@ miller-rabin@^4.0.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.37.0.tgz#0b6a0ce6fdbe9576e25f1f2d2fde8830dc0ad0d8" integrity sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg== -mime-types@~2.1.17, mime-types@~2.1.18: +mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.18, mime-types@~2.1.19: version "2.1.21" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.21.tgz#28995aa1ecb770742fe6ae7e58f9181c744b3f96" integrity sha512-3iL6DbwpyLzjR3xHSFNFeb9Nz/M8WDkX33t1GFQnFOllWk8pOrh/LSrB5OXlnlW5P9LH73X6loW/eogc+F5lJg== @@ -5594,7 +6736,7 @@ minimist@0.0.8: resolved "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= -minimist@1.2.0, minimist@^1.2.0: +minimist@1.2.0, minimist@^1.1.1, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= @@ -5791,6 +6933,11 @@ node-forge@0.6.33: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.6.33.tgz#463811879f573d45155ad6a9f43dc296e8e85ebc" integrity sha1-RjgRh59XPUUVWtap9D3ClujoXrw= +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= + "node-libs-browser@^1.0.0 || ^2.0.0", node-libs-browser@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.1.0.tgz#5f94263d404f6e44767d726901fff05478d600df" @@ -5820,6 +6967,16 @@ node-forge@0.6.33: util "^0.10.3" vm-browserify "0.0.4" +node-notifier@^5.2.1: + version "5.3.0" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-5.3.0.tgz#c77a4a7b84038733d5fb351aafd8a268bfe19a01" + integrity sha512-AhENzCSGZnZJgBARsUjnQ7DnZbzyP+HxlVXuD0xqAnvL8q+OqtSX7lGg9e8nHzwXkMMXNdVeqq4E2M3EUAqX6Q== + dependencies: + growly "^1.3.0" + semver "^5.5.0" + shellwords "^0.1.1" + which "^1.3.0" + node-pre-gyp@^0.10.0: version "0.10.3" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" @@ -5891,7 +7048,7 @@ normalize-package-data@^2.3.2: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" -normalize-path@^2.1.1: +normalize-path@^2.0.1, normalize-path@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= @@ -5947,6 +7104,16 @@ number-is-nan@^1.0.0: resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= +nwsapi@^2.0.7: + version "2.0.9" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.0.9.tgz#77ac0cdfdcad52b6a1151a84e73254edc33ed016" + integrity sha512-nlWFSCTYQcHk/6A9FFnfhKc14c3aFhfdNBXgo8Qgi9QTBu/qg3Ww+Uiz9wMzXd1T8GFxPc2QIHB6Qtf2XFryFQ== + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + object-assign@^4.0.1, object-assign@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -6006,6 +7173,14 @@ object.getownpropertydescriptors@^2.0.3: define-properties "^1.1.2" es-abstract "^1.5.1" +object.omit@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" + integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo= + dependencies: + for-own "^0.1.4" + is-extendable "^0.1.1" + object.pick@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" @@ -6120,6 +7295,15 @@ os-homedir@^1.0.0: resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= +os-locale@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" + integrity sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA== + dependencies: + execa "^0.7.0" + lcid "^1.0.0" + mem "^1.1.0" + os-locale@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.0.1.tgz#3b014fbf01d87f60a1e5348d80fe870dc82c4620" @@ -6129,7 +7313,7 @@ os-locale@^3.0.0: lcid "^2.0.0" mem "^4.0.0" -os-tmpdir@^1.0.0, os-tmpdir@~1.0.2: +os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= @@ -6247,6 +7431,16 @@ parse-asn1@^5.0.0: evp_bytestokey "^1.0.0" pbkdf2 "^3.0.3" +parse-glob@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" + integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw= + dependencies: + glob-base "^0.3.0" + is-dotfile "^1.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.0" + parse-json@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" @@ -6262,6 +7456,11 @@ parse-json@^4.0.0: error-ex "^1.3.1" json-parse-better-errors "^1.0.1" +parse5@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" + integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA== + parse5@^5: version "5.0.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.0.0.tgz#4d02710d44f3c3846197a11e205d4ef17842b81a" @@ -6313,7 +7512,7 @@ path-exists@^3.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= -path-is-absolute@^1.0.0: +path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= @@ -6338,6 +7537,15 @@ path-to-regexp@0.1.7: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-type@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" + integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE= + dependencies: + graceful-fs "^4.1.2" + pify "^2.0.0" + pinkie-promise "^2.0.0" + path-type@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" @@ -6370,6 +7578,11 @@ pbkdf2@^3.0.3: safe-buffer "^5.0.1" sha.js "^2.4.8" +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -6425,6 +7638,11 @@ pluralize@^7.0.0: resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777" integrity sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow== +pn@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" + integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== + pofile@^1: version "1.0.11" resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.0.11.tgz#35aff58c17491d127a07336d5522ebc9df57c954" @@ -6518,6 +7736,11 @@ prepend-http@^2.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= +preserve@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" + integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks= + prettier@1.13.7: version "1.13.7" resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.13.7.tgz#850f3b8af784a49a6ea2d2eaa7ed1428a34b7281" @@ -6528,6 +7751,14 @@ prettier@1.15.2: resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.15.2.tgz#d31abe22afa4351efa14c7f8b94b58bb7452205e" integrity sha512-YgPLFFA0CdKL4Eg2IHtUSjzj/BWgszDHiNQAe0VAIBse34148whfdzLagRL+QiKS+YfK5ftB6X4v/MBw8yCoug== +pretty-format@^23.6.0: + version "23.6.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-23.6.0.tgz#5eaac8eeb6b33b987b7fe6097ea6a8a146ab5760" + integrity sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw== + dependencies: + ansi-regex "^3.0.0" + ansi-styles "^3.2.0" + prismjs@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.6.0.tgz#118d95fb7a66dba2272e343b345f5236659db365" @@ -6535,7 +7766,7 @@ prismjs@^1.6.0: optionalDependencies: clipboard "^1.5.5" -private@^0.1.6: +private@^0.1.6, private@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg== @@ -6565,6 +7796,14 @@ promise-inflight@^1.0.1: resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= +prompts@^0.1.9: + version "0.1.14" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-0.1.14.tgz#a8e15c612c5c9ec8f8111847df3337c9cbd443b2" + integrity sha512-rxkyiE9YH6zAz/rZpywySLKkpaj0NMVyNw1qhsubdbjjSgcayjTShDreZGlFMcGSu5sab3bAKPfFk78PB90+8w== + dependencies: + kleur "^2.0.1" + sisteransi "^0.1.1" + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -6595,6 +7834,11 @@ pseudomap@^1.0.2: resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= +psl@^1.1.24, psl@^1.1.28: + version "1.1.29" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.29.tgz#60f580d360170bb722a797cc704411e6da850c67" + integrity sha512-AeUmQ0oLN02flVHXWh9sSJF7mcdFq0ppid/JkErufc3hGIV/AMa8Fo9VgDo/cT2jFdOWoFvHp90qqBH54W+gjQ== + pstree.remy@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.0.tgz#f2af27265bd3e5b32bbfcc10e80bac55ba78688b" @@ -6643,7 +7887,12 @@ punycode@1.3.2, punycode@^1.2.4: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^2.1.0: +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + +punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== @@ -6658,6 +7907,11 @@ qs@6.5.1: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" integrity sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A== +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== + query-string@^5.0.1: version "5.1.1" resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb" @@ -6682,6 +7936,15 @@ querystringify@^2.0.0: resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.0.tgz#7ded8dfbf7879dcc60d0a644ac6754b283ad17ef" integrity sha512-sluvZZ1YiTLD5jsqZcDmFyV2EwToyXZBfpoVOmktMmW+VEnhgakFHnasVph65fOjGPTWN0Nw3+XQaSeMayr0kg== +randomatic@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed" + integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw== + dependencies: + is-number "^4.0.0" + kind-of "^6.0.0" + math-random "^1.0.1" + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.6.tgz#d302c522948588848a8d300c932b44c24231da80" @@ -6739,6 +8002,14 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +read-pkg-up@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" + integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI= + dependencies: + find-up "^1.0.0" + read-pkg "^1.0.0" + read-pkg-up@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" @@ -6755,6 +8026,15 @@ read-pkg-up@^4.0.0: find-up "^3.0.0" read-pkg "^3.0.0" +read-pkg@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" + integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg= + dependencies: + load-json-file "^1.0.0" + normalize-package-data "^2.3.2" + path-type "^1.0.0" + read-pkg@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" @@ -6808,6 +8088,13 @@ readdirp@^2.0.0: readable-stream "^2.0.2" set-immediate-shim "^1.0.1" +realpath-native@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.0.2.tgz#cd51ce089b513b45cf9b1516c82989b51ccc6560" + integrity sha512-+S3zTvVt9yTntFrBpm7TQmQ3tzpCrnA1a/y+3cUHAc9ZR6aIjG0WNLR+Rj79QpJktY+VeW/TQtFlQ1bzsehI8g== + dependencies: + util.promisify "^1.0.0" + regenerate-unicode-properties@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-7.0.0.tgz#107405afcc4a190ec5ed450ecaa00ed0cafa7a4c" @@ -6837,6 +8124,13 @@ regenerator-transform@^0.13.3: dependencies: private "^0.1.6" +regex-cache@^0.4.2: + version "0.4.4" + resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd" + integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ== + dependencies: + is-equal-shallow "^0.1.3" + regex-not@^1.0.0, regex-not@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" @@ -6925,11 +8219,60 @@ repeat-string@^0.2.2: resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-0.2.2.tgz#c7a8d3236068362059a7e4651fc6884e8b1fb4ae" integrity sha1-x6jTI2BoNiBZp+RlH8aITosftK4= -repeat-string@^1.6.1: +repeat-string@^1.5.2, repeat-string@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= +repeating@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" + integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= + dependencies: + is-finite "^1.0.0" + +request-promise-core@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.1.tgz#3eee00b2c5aa83239cfb04c5700da36f81cd08b6" + integrity sha1-Pu4AssWqgyOc+wTFcA2jb4HNCLY= + dependencies: + lodash "^4.13.1" + +request-promise-native@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.5.tgz#5281770f68e0c9719e5163fd3fab482215f4fda5" + integrity sha1-UoF3D2jgyXGeUWP9P6tIIhX0/aU= + dependencies: + request-promise-core "1.1.1" + stealthy-require "^1.1.0" + tough-cookie ">=2.3.3" + +request@^2.87.0: + version "2.88.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" + integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.0" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.4.3" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -6975,7 +8318,7 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@1.1.x: +resolve@1.1.7, resolve@1.1.x: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= @@ -7027,6 +8370,11 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^2.0.0" inherits "^2.0.1" +rsvp@^3.3.3: + version "3.6.2" + resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a" + integrity sha512-OfWGQTb9vnwRjwtA2QwpG2ICclHC3pgXZO5xt8H2EfgDquO0qVdSb5T88L4qJVAEugbS56pAuV4XZM58UX8ulw== + run-async@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" @@ -7075,11 +8423,27 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3": +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +sane@^2.0.0: + version "2.5.2" + resolved "https://registry.yarnpkg.com/sane/-/sane-2.5.2.tgz#b4dc1861c21b427e929507a3e751e2a2cb8ab3fa" + integrity sha1-tNwYYcIbQn6SlQej51HiosuKs/o= + dependencies: + anymatch "^2.0.0" + capture-exit "^1.2.0" + exec-sh "^0.2.0" + fb-watchman "^2.0.0" + micromatch "^3.1.4" + minimist "^1.1.1" + walker "~1.0.5" + watch "~0.18.0" + optionalDependencies: + fsevents "^1.2.3" + sanitize-html@^1.16.1: version "1.16.3" resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.16.3.tgz#96c1b44a36ff7312e1c22a14b05274370ac8bd56" @@ -7285,6 +8649,11 @@ shebang-regex@^1.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= +shellwords@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" + integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== + sigmund@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" @@ -7295,6 +8664,16 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= +sisteransi@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-0.1.1.tgz#5431447d5f7d1675aac667ccd0b865a4994cb3ce" + integrity sha512-PmGOd02bM9YO5ifxpw36nrNMBTptEtfRl4qUYl9SndkolplkrZZOW7PGHjrZL53QvMVj9nQ+TKqUnRsw4tJa4g== + +slash@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" + integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= + slice-ansi@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d" @@ -7307,12 +8686,12 @@ slugify@^1.3.1: resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.3.1.tgz#f572127e8535329fbc6c1edb74ab856b61ad7de2" integrity sha512-6BwyhjF5tG5P8s+0DPNyJmBSBePG6iMyhjvIW5zGdA3tFik9PtK+yNkZgTeiroCRGZYgkHftFA62tGVK1EI9Kw== -smooshpack@^0.0.48: - version "0.0.48" - resolved "https://registry.yarnpkg.com/smooshpack/-/smooshpack-0.0.48.tgz#6fbeaaf59226a1fe500f56aa17185eed377d2823" - integrity sha512-BmaIr6fK6w7WBCI4V7tcZIg78WeE6X56zrhGSNk5vXavT1bQPXH+brZFq6Hwi833upY/yusG2FMVkf7TZsVv/w== +smooshpack@^0.0.53: + version "0.0.53" + resolved "https://registry.yarnpkg.com/smooshpack/-/smooshpack-0.0.53.tgz#aa397ca43619912e8ac0aa32012846ff4feaa5e8" + integrity sha512-FVXvKvZOz5+Tk/zUJ/wxM+ftu1yZtFEmeQl4chCqbzK/reU0L/OdDiYpx+/27Jt2VX09j08oIzwoyQ5fHH4+WQ== dependencies: - codesandbox-api "^0.0.18" + codesandbox-api "^0.0.20" codesandbox-import-utils "^1.2.3" lodash.isequal "^4.5.0" @@ -7440,6 +8819,21 @@ source-map-resolve@^0.5.0: source-map-url "^0.4.0" urix "^0.1.0" +source-map-support@^0.4.15: + version "0.4.18" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" + integrity sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA== + dependencies: + source-map "^0.5.6" + +source-map-support@^0.5.6: + version "0.5.9" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.9.tgz#41bc953b2534267ea2d605bccfa7bfa3111ced5f" + integrity sha512-gR6Rw4MvUlYy83vP0vxoVNzM6t8MUXqNuRsuBmBHQDu1Fh6X015FrLdgoDKcNdkwGubozq0P4N0Q37UyFVr1EA== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + source-map-url@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" @@ -7450,12 +8844,12 @@ source-map@0.5.0: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.0.tgz#0fe96503ac86a5adb5de63f4e412ae4872cdbe86" integrity sha1-D+llA6yGpa213mP05BKuSHLNvoY= -source-map@^0.5.0, source-map@^0.5.6: +source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= -source-map@^0.6.1, source-map@~0.6.1: +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -7541,6 +8935,21 @@ srcset@^1.0.0: array-uniq "^1.0.2" number-is-nan "^1.0.0" +sshpk@^1.7.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.15.2.tgz#c946d6bd9b1a39d0e8635763f5242d6ed6dcb629" + integrity sha512-Ra/OXQtuh0/enyl4ETZAfTaeksa6BXks5ZcjpSUNrjBr0DvrJKX+1fsKDPpT9TBXgHAFsa4510aNVgI8g/+SzA== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + ssri@^5.2.4: version "5.2.4" resolved "https://registry.yarnpkg.com/ssri/-/ssri-5.2.4.tgz#9985e14041e65fc397af96542be35724ac11da52" @@ -7555,6 +8964,11 @@ ssri@^6.0.0: dependencies: figgy-pudding "^3.5.1" +stack-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8" + integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA== + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" @@ -7573,6 +8987,11 @@ statuses@~1.3.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" integrity sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4= +stealthy-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" + integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= + stickyfilljs@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/stickyfilljs/-/stickyfilljs-2.0.5.tgz#d229e372d2199ddf5d283bbe34ac1f7d2529c2fc" @@ -7632,6 +9051,14 @@ strict-uri-encode@^1.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= +string-length@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed" + integrity sha1-1A27aGo6zpYMHP/KVivyxF+DY+0= + dependencies: + astral-regex "^1.0.0" + strip-ansi "^4.0.0" + string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" @@ -7675,11 +9102,18 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" -strip-bom@^3.0.0: +strip-bom@3.0.0, strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= +strip-bom@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" + integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4= + dependencies: + is-utf8 "^0.2.0" + strip-css-comments@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-css-comments/-/strip-css-comments-3.0.0.tgz#7a5625eff8a2b226cf8947a11254da96e13dae89" @@ -7710,7 +9144,7 @@ supports-color@^2.0.0: resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= -supports-color@^3.1.0: +supports-color@^3.1.0, supports-color@^3.1.2: version "3.2.3" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" integrity sha1-ZawFBLOVQXHYpklGsq48u4pfVPY= @@ -7734,6 +9168,11 @@ symbol-observable@^1.0.2: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== +symbol-tree@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" + integrity sha1-rifbOPZgp64uHDt9G8KQgZuFGeY= + table@^4.0.3: version "4.0.3" resolved "http://registry.npmjs.org/table/-/table-4.0.3.tgz#00b5e2b602f1794b9acaf9ca908a76386a7813bc" @@ -7776,6 +9215,17 @@ term-size@^1.2.0: dependencies: execa "^0.7.0" +test-exclude@^4.2.1: + version "4.2.3" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-4.2.3.tgz#a9a5e64474e4398339245a0a769ad7c2f4a97c20" + integrity sha512-SYbXgY64PT+4GAL2ocI3HwPa4Q4TBKm0cwAVeKOt/Aoc0gSpNRjJX8w0pA1LMKZ3LBmd8pYBqApFNQLII9kavA== + dependencies: + arrify "^1.0.1" + micromatch "^2.3.11" + object-assign "^4.1.0" + read-pkg-up "^1.0.1" + require-main-filename "^1.0.1" + test-exclude@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.0.0.tgz#cdce7cece785e0e829cd5c2b27baf18bc583cfb7" @@ -7811,6 +9261,11 @@ three@^0.84.0: resolved "https://registry.yarnpkg.com/three/-/three-0.84.0.tgz#95be85a55a0fa002aa625ed559130957dcffd918" integrity sha1-lb6FpVoPoAKqYl7VWRMJV9z/2Rg= +throat@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" + integrity sha1-iQN8vJLFarGJJua6TLsgDhVnKmo= + throttle-debounce@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.0.1.tgz#7307ddd6cd9acadb349132fbf6c18d78c88a5e62" @@ -7865,6 +9320,11 @@ tmp@0.0.33, tmp@0.0.x, tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" +tmpl@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" + integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + to-array@0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890" @@ -7922,6 +9382,29 @@ touch@^3.1.0: dependencies: nopt "~1.0.10" +tough-cookie@>=2.3.3, tough-cookie@^2.3.4: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tough-cookie@~2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" + integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== + dependencies: + psl "^1.1.24" + punycode "^1.4.1" + +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk= + dependencies: + punycode "^2.1.0" + trim-right@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" @@ -7942,6 +9425,18 @@ tty-browserify@0.0.0: resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + type-check@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" @@ -8237,6 +9732,15 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + visibilityjs@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/visibilityjs/-/visibilityjs-1.2.4.tgz#bff8663da62c8c10ad4ee5ae6a1ae6fac4259d63" @@ -8343,6 +9847,28 @@ vuex@^3.0.1: resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.0.1.tgz#e761352ebe0af537d4bb755a9b9dc4be3df7efd2" integrity sha512-wLoqz0B7DSZtgbWL1ShIBBCjv22GV5U+vcBFox658g6V0s4wZV9P4YjCNyoHSyIBpj1f29JBoNQIqD82cR4O3w== +w3c-hr-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045" + integrity sha1-gqwr/2PZUOqeMYmlimViX+3xkEU= + dependencies: + browser-process-hrtime "^0.1.2" + +walker@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" + integrity sha1-L3+bj9ENZ3JisYqITijRlhjgKPs= + dependencies: + makeerror "1.0.x" + +watch@~0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/watch/-/watch-0.18.0.tgz#28095476c6df7c90c963138990c0a5423eb4b986" + integrity sha1-KAlUdsbffJDJYxOJkMClQj60uYY= + dependencies: + exec-sh "^0.2.0" + minimist "^1.2.0" + watchpack@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.5.0.tgz#231e783af830a22f8966f65c4c4bacc814072eed" @@ -8359,6 +9885,11 @@ wbuf@^1.1.0, wbuf@^1.7.2: dependencies: minimalistic-assert "^1.0.0" +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + webpack-bundle-analyzer@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.0.2.tgz#22f19ea6d1b5a15fd7a90baae0bc0f39bd1e4d48" @@ -8501,6 +10032,36 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.1.tgz#76899499c184b6ef754377c2dbb0cd6cb55d29e7" integrity sha1-domUmcGEtu91Q3fC27DNbLVdKec= +whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: + version "1.0.5" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" + integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== + dependencies: + iconv-lite "0.4.24" + +whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" + integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== + +whatwg-url@^6.4.1: + version "6.5.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8" + integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +whatwg-url@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.0.0.tgz#fde926fa54a599f3adf82dff25a9f7be02dc6edd" + integrity sha512-37GeVSIJ3kn1JgKyjiYNmSLP1yzbpb29jdmwBSgkD9h40/hyrR/OifpVUndji3tmwGgD8qpw7iQu3RSbCrBpsQ== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + which-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" @@ -8513,6 +10074,13 @@ which@^1.1.1, which@^1.2.1, which@^1.2.9: dependencies: isexe "^2.0.0" +which@^1.2.12, which@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + wide-align@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710" @@ -8566,7 +10134,7 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= -write-file-atomic@^2.0.0: +write-file-atomic@^2.0.0, write-file-atomic@^2.1.0: version "2.3.0" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.3.0.tgz#1ff61575c2e2a4e8e510d6fa4e243cce183999ab" integrity sha512-xuPeK4OdjWqtfi59ylvVL0Yn35SF3zgcAcv7rBPFHVaEapaDr4GdGgm3j7ckTwH9wHL7fGmgfAnb0+THrHb8tA== @@ -8582,6 +10150,13 @@ write@^0.2.1: dependencies: mkdirp "^0.5.1" +ws@^5.2.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f" + integrity sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA== + dependencies: + async-limiter "~1.0.0" + ws@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/ws/-/ws-6.0.0.tgz#eaa494aded00ac4289d455bac8d84c7c651cef35" @@ -8603,6 +10178,16 @@ xdg-basedir@^3.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ= +xml-name-validator@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" + integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== + +xml@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" + integrity sha1-eLpyAgApxbyHuKgaPPzXS0ovweU= + xmlbuilder@8.2.2: version "8.2.2" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773" @@ -8633,6 +10218,11 @@ xterm@^3.5.0: resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.5.0.tgz#ba3f464bc5730c9d259ebe62131862224db9ddcc" integrity sha512-IpG3P3gkT0/xDPS0j3igpk92JYlUajaEHk3/EQSUeIRJmPiF2lyham3Xt/GD3o98uOrRluvowjNj0AFeYK+AXQ== +y18n@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" + integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= + "y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" @@ -8655,6 +10245,13 @@ yargs-parser@^10.1.0: dependencies: camelcase "^4.1.0" +yargs-parser@^9.0.2: + version "9.0.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-9.0.2.tgz#9ccf6a43460fe4ed40a9bb68f48d43b8a68cc077" + integrity sha1-nM9qQ0YP5O1Aqbto9I1DuKaMwHc= + dependencies: + camelcase "^4.1.0" + yargs@12.0.2, yargs@^12.0.1: version "12.0.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.2.tgz#fe58234369392af33ecbef53819171eff0f5aadc" @@ -8673,6 +10270,24 @@ yargs@12.0.2, yargs@^12.0.1: y18n "^3.2.1 || ^4.0.0" yargs-parser "^10.1.0" +yargs@^11.0.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-11.1.0.tgz#90b869934ed6e871115ea2ff58b03f4724ed2d77" + integrity sha512-NwW69J42EsCSanF8kyn5upxvjp5ds+t3+udGBeTbFnERA+lF541DDpMawzo4z6W/QrzNM18D+BPMiOBibnFV5A== + dependencies: + cliui "^4.0.0" + decamelize "^1.1.1" + find-up "^2.1.0" + get-caller-file "^1.0.1" + os-locale "^2.0.0" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^2.0.0" + which-module "^2.0.0" + y18n "^3.2.1" + yargs-parser "^9.0.2" + yeast@0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" @@ -8689,3 +10304,8 @@ zen-observable@^0.8.0: version "0.8.11" resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.11.tgz#d3415885eeeb42ee5abb9821c95bb518fcd6d199" integrity sha512-N3xXQVr4L61rZvGMpWe8XoCGX8vhU35dPyQ4fm5CY/KDlG0F75un14hjbckPXTDuKUY6V0dqR2giT6xN8Y4GEQ== + +zrender@4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/zrender/-/zrender-4.0.5.tgz#6e8f738971ce2cd624aac82b2156729b1c0e5a82" + integrity sha512-SintgipGEJPT9Sz2ABRoE4ZD7Yzy7oR7j7KP6H+C9FlbHWnLUfGVK7E8UV27pGwlxAMB0EsnrqhXx5XjAfv/KA== |